agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
751 lines • 35.6 kB
JavaScript
"use strict";
/**
* time/readers/english — the library's careful English reader (time
* design § 5.3): a tokenizer over a small, closed set of phrases, and
* "unreadable" for every other time phrase it recognises.
*
* Pattern: Strategy behind the `TimeReader` port (`../reader.ts`). It
* TOKENIZES only: it returns the parts it sees and the verbatim
* quote. It never resolves an instant, never applies a date order,
* never maps a zone token (an abbreviation, a place) to a zone and
* never refuses a future date —
* all of that is `resolve.ts`'s, the policy's or the ask's.
* Role: L0 leaf (the time layer). Imports only the port's types. Armed
* by the app: `.time({ reader: englishTimeReader() })` — there is no
* default reader.
* Emits: N/A.
*
* ## What it reads (the v1 rows of § 5.3)
*
* | Phrase | Example | Parts |
* |--------|---------|-------|
* | an ISO date or instant | `2026-10-09`, `2026-10-09T08:00-07:00` | `date: fixed`, `wall` (marked `clock: '24h'` — never also pm), `zoneToken` |
* | a numeric date | `10/09/26`, `10/9` | `date: numeric` — the ORDER is not decided here |
* | a clock time | `8 AM`, `8:40`, `20:40`, `8:40 p.m.` | `wall` (a bare `8` is a time only as a range's first side: `8 to 9 AM`) |
* | a range between two of the above | `8 AM to 8:40 AM`, `08:00–08:40`, `between 8 and 9 AM`, `today between 1 pm and 2 pm` | `rangeOf` (a day or zone said once for both sides is the whole mention's) |
* | a zone after a time, a date or a day word | `America/Los_Angeles`, `UTC-07:00`, `London time`, `PST` | `zoneToken`, as written — `resolve.ts` maps it (a place the tz database names once; an abbreviation only through the app's map; else ASKED) |
* | a named-month date | `11 September`, `Sept 29`, `September 29, 2026`, `the 29th of Sep` | `date: fixed` — no `year` unless written (the policy's or the person's, as for `10/9`) |
* | a day word | today, yesterday, tomorrow | `relative: { unit: 'day', offset }` |
* | a relative span from now | last 40 minutes, past 2 hours, last 24h, past 6h, the last hour, the past week | `relative: { unit, count }` — a look-back in minutes, hours, days or weeks, spelled out or compact (`m`, `h`, `d`, `w`) |
*
* ## What it says "unreadable" for — never a partial reading
*
* Parts of the day (`yesterday morning`, `tonight`, `noon` — a greeting, `Good morning`, is
* no time and reads no mention), calendar spans
* (`last week`, `this month`), week days and a month named alone (`Friday`,
* `in September`), `N hours ago`, spans in words (`last two hours`), in a unit it
* does not read (`last 30 seconds`, `last 30s`, `last 3 months`) or said with `previous`
* (`previous 7 days` — often relative to another window), a look-ahead (`next 2 hours`), an
* ordinal day (`the 9th`), `8 o'clock`, and any v1 phrase a modifier changes
* (`since 8 AM`, `before yesterday`, `around 8:40`, `earlier today`,
* `8 AM to now`, `past 8 PM`, `8 AM-ish`, `from 3 PM yesterday` with no `to`), a
* bare first side whose far side says no meridiem (`8 to 9:30` — `9:30` alone
* would drop the start) and a meridiem the number contradicts (`13:00 PM`). Such a phrase is ONE mention
* with `problem: 'unreadable'`, quoting the whole phrase: reading `yesterday`
* out of `yesterday morning` would silently widen what the person said.
*
* ## It only PROPOSES — the person confirms (the owner's decision "Always confirm")
*
* Nothing this reader reads is ever filed as the person's words. Seven review
* rounds each found the next English spelling a word list, an allow-list or a
* position rule missed (a zone named in words, a look-back tied to an event,
* an open end, a half-read range), so the owner decided (2026-09-30, time
* design TQ29): every reading of a chat message is a PROPOSAL, offered
* through the time ask pre-filled and editable, with its window AND its zone
* ("I read “last 2 hours” as … in America/Los_Angeles — is that right?").
* Only what the person picks or types in that form is theirs. The library
* owns the law (`../rowsBuild.ts` · `timeReadingRows`), so this reader returns
* parts and quotes only — no confirm flag, no leftover list, no allow-list.
* A form may later skip the click only when a registered benchmark shows it
* is always read right (TQ29's growth rule).
*
* A numeric date the tokens could split two ways stays ONE parse — `10/09/26`
* is three numbers; which is the month is the policy's or the person's.
*
* @example
* ```ts
* const reader = englishTimeReader();
* reader.read('errors on 10/09/26 8 AM to 8:40 AM PST', { locale: 'en-US' });
* // { mentions: [{ quote: '10/09/26 8 AM to 8:40 AM PST', parses: [{
* // date: { kind: 'numeric', fields: [10, 9, 26], yearDigits: 2 }, zoneToken: 'PST',
* // rangeOf: [{ wall: { h: 8, meridiem: 'am' } }, { wall: { h: 8, m: 40, meridiem: 'am' } }] }] }] }
* reader.read('errors in the last 2 hours', { locale: 'en-US' });
* // { mentions: [{ quote: 'last 2 hours', parses: [{ relative: { unit: 'hour', count: 2 } }] }] }
* reader.read('what failed yesterday morning?', { locale: 'en-US' });
* // { mentions: [{ quote: 'yesterday morning', parses: [], problem: 'unreadable' }] }
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.englishTimeReader = exports.windowBefore = exports.saysBetweenBefore = exports.coverage = exports.BARE_SIDE_BEFORE = exports.FROM_BEFORE = exports.MODIFIER_BEFORE = exports.ENGLISH_TIME_READER_VERSION = exports.ENGLISH_TIME_READER_ID = void 0;
const reader_js_1 = require("../reader.js");
// ─── The words (data) ────────────────────────────────────────────────────
/** The reader's recorded identity — on every `time-reading` row with its version. */
exports.ENGLISH_TIME_READER_ID = 'agentfootprint/english';
/** Raised whenever what the reader returns for some text changes. */
exports.ENGLISH_TIME_READER_VERSION = '1.2.0';
const DAY_WORDS = { today: 0, yesterday: -1, tomorrow: 1 };
/** The units a look-back is read in: minutes, hours, days, weeks — spelled out or compact (`24h`). */
const SPAN_UNITS = {
minute: 'minute',
min: 'minute',
m: 'minute',
hour: 'hour',
hr: 'hour',
h: 'hour',
day: 'day',
d: 'day',
week: 'week',
wk: 'week',
w: 'week',
};
/** A month's name as written (any case; `May` capitalised only — `may` is a verb) → its number. */
const MONTH_NUMBERS = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
}; // prettier-ignore
/** Zone abbreviations the reader TOKENIZES after a time or a date — never maps (§ 5.3). */
const ZONE_ABBREVIATIONS = [
'UTC', 'GMT', 'PST', 'PDT', 'PT', 'MST', 'MDT', 'MT', 'CST', 'CDT', 'CT', 'EST', 'EDT', 'ET',
'AKST', 'AKDT', 'HST', 'AST', 'ADT', 'NST', 'NDT', 'BST', 'IST', 'CET', 'CEST', 'EET', 'EEST',
'WET', 'WEST', 'MSK', 'JST', 'KST', 'HKT', 'SGT', 'AEST', 'AEDT', 'ACST', 'ACDT', 'AWST',
'NZST', 'NZDT', 'SAST',
]; // prettier-ignore
const IANA_AREAS = 'Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific|Etc';
const UNIT_WORDS = 'seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?|quarters?';
const NUMBER_WORDS = 'an?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fifteen|twenty|thirty|' +
'forty|fifty|sixty|ninety|few|several|couple(?:\\s+of)?';
const MONTHS = 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|' +
'sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
/**
* Time phrases v1 does not read (§ 5.3's "later" rows and everything past
* them). Each forms an `unreadable` mention on its own, and taints any v1
* phrase it touches.
*/
const NOT_READ = [
// A part of the day — never after `good` (`Good morning`, `good night`): a greeting names no
// time, and an unreadable mention is ASKED, so a greeting read here would ask the person which
// time they meant by words that meant none.
/\b(?<!\bgood\s+)(?:(?:this|last|early|late|mid)\s+)?(?:morning|afternoon|evening|night|tonight|overnight|noon|midday|midnight|lunchtime|dawn|dusk)s?\b/gi,
/\b(?:last|this|next|past|previous|coming|current)\s+(?:week|month|year|quarter|weekend|hour|minute|second|day|night|decade|fortnight|shift)\b/gi,
/\bweekends?\b/gi,
/\b(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?\b/gi,
// A month named alone (`in September`) — a calendar month, not read; with a day it is a date (`MONTH_DATE`).
/\b(?:january|february|july|september|october|november|december)\b/gi,
new RegExp(`\\b(?:\\d+|${NUMBER_WORDS})\\s+(?:${UNIT_WORDS})\\s+ago\\b`, 'gi'),
new RegExp(`\\b(?:last|past|previous|next|coming|upcoming|following)\\s+` +
`(?:(?:${NUMBER_WORDS}|\\d+)\\s+)?(?:${UNIT_WORDS})\\b`, 'gi'),
// A compact span the reader does not read (`next 2h`, `last 30s`, `past 3mo`) — never no mention.
/\b(?:last|past|previous|next|coming|upcoming|following)\s+\d+\s*(?:s|secs?|m|mins?|h|hrs?|d|w|wks?|mo|mos|y|yrs?)\b/gi,
/\b(?:the\s+)?day\s+(?:before\s+yesterday|after\s+tomorrow)\b/gi,
new RegExp(`\\bon\\s+the\\s+\\d{1,2}(?:st|nd|rd|th)\\b(?!\\s+of\\s+(?:${MONTHS})\\b)`, 'gi'),
// `the 9th of` — unless a month follows (`the 29th of September` is a date, `MONTH_DATE`).
new RegExp(`\\bthe\\s+\\d{1,2}(?:st|nd|rd|th)\\s+of\\b(?!\\s+(?:${MONTHS})\\b)`, 'gi'),
/\b\d{1,2}\s*o['’]?\s?clock\b/gi,
// A meridiem on a number that is no 12-hour time (`13:00 PM`, `0 AM`): the words contradict.
/(?<![\w:.+/])\d{1,2}(?::\d{2}){0,2}\s?(?:am|pm|a\.m\.|p\.m\.)(?![a-z])/gi,
];
// ─── The range grammar (one owner) ──────────────────────────────────────
//
// The connectors a v1 range is READ with — the grammar's own list: the joins
// (`joins`), the bare hour's lookahead (`BARE_HOUR`) and `… to now`
// (`MODIFIER_AFTER`) all build from it. What the reader did not read is not
// its question any more: every reading is a proposal the person confirms
// (the owner's decision "Always confirm", time design TQ29).
const RANGE_WORDS = 'to|until|till|through|thru';
/** A range connector the grammar reads: a word, or a hyphen, en dash or em dash. */
const RANGE_CONNECTOR = `(?:(?:${RANGE_WORDS})(?![a-z])|[–—-])`;
/** A word right before a phrase that changes what it means: the phrase is not read. */
exports.MODIFIER_BEFORE = new RegExp('(?:^|[^\\w])(since|before|after|by|around|circa|approx(?:imately)?|until|till|earlier|later|' +
'early|late|within|past|beyond|prior\\s+to|up\\s+to|no\\s+(?:later|earlier|sooner)\\s+than|' +
'as\\s+of|(?:starting|beginning)(?:\\s+(?:at|from|on))?|~)\\s*$', 'i');
/** `from` with no `to`: a time after it is where a window STARTS (`from 3 PM yesterday`), not an hour. */
exports.FROM_BEFORE = /(?:^|[^\w])(from)\s*$/i;
/**
* A bare number joined to a clock time as a range's first side the grammar
* does not read (`8 to 9:30`, `from 8 to 9:30`, `between 8 and 9:30` — the
* far side says no meridiem, so `8` is no time): the whole phrase is not
* read. Reading `9:30` alone would drop the start the person said.
*/
exports.BARE_SIDE_BEFORE = new RegExp(`(?:^|[^\\w:./-])((?:from\\s+)?\\d{1,2}\\s*${RANGE_CONNECTOR}|between\\s+\\d{1,2}\\s+and)\\s*$`, 'i');
/** …and right after it. */
const MODIFIER_AFTER = new RegExp('^(?:\\s*-?\\s*ish\\b|\\s*(?:ago\\b|onwards?\\b|or\\s+so\\b|(?:or|and)\\s+(?:later|earlier|after|before)\\b|' +
`at\\s+the\\s+(?:latest|earliest)\\b|${RANGE_CONNECTOR}\\s*now\\b))`, 'i');
/** Text not preceded by something that would make these digits part of a larger token. */
const NOT_AFTER = '(?<![\\w:./-])';
/**
* The same for a clock time — which may follow a range's hyphen (`8:00-8:40`)
* or `..` (`2026-09-26 08:00..08:40`), never an offset's sign or one dot.
*/
const TIME_NOT_AFTER = '(?<![\\w:+/])(?<!(?:^|[^.])\\.)';
const ISO = new RegExp(
// An ISO interval's second end follows its first's `/` (`…08:00Z/2026-…`) or `..`; a path's `/` does not count.
`(?<![\\w:-])(?<!(?:^|[^.])\\.)(?<![^\\dZ]/)(?<!^/)(\\d{4})-(\\d{2})-(\\d{2})` +
'(?:[Tt ](\\d{2}):(\\d{2})(?::(\\d{2}))?(Z|[+-]\\d{2}(?::?\\d{2})?)?)?' +
'(?![\\w:]|[.-]\\d)', 'g');
const NUMERIC = new RegExp(
// A hyphen may join two dates (`10/01/26-10/09/26`).
`(?<![\\w:./])(\\d{1,4})/(\\d{1,2})(?:/(\\d{4}|\\d{2}))?(?![\\w/:]|\\.\\d)`, 'g');
const WALL_MERIDIEM = new RegExp(`${TIME_NOT_AFTER}(\\d{1,2})(?::(\\d{2})(?::(\\d{2}))?)?\\s?(am|pm|a\\.m\\.|p\\.m\\.)(?![a-z])`, 'gi');
const WALL_COLON = new RegExp(
// A meridiem after it is `WALL_MERIDIEM`'s — or, when that refuses it (`13:00 PM`), unreadable.
`${TIME_NOT_AFTER}(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?![\\d:])(?!\\s?(?:am|pm|a\\.m\\.|p\\.m\\.)(?![a-z]))`, 'gi');
/** A bare hour — a time only as a range's first side whose second side carries a meridiem. */
const BARE_HOUR = new RegExp(`${NOT_AFTER}(\\d{1,2})(?=\\s*(?:${RANGE_CONNECTOR}|and)\\s*` +
'\\d{1,2}(?::\\d{2})?\\s?(?:am|pm|a\\.m\\.|p\\.m\\.)(?![a-z]))', 'gi');
const DAY_WORD = /\b(today|yesterday|tomorrow)\b/gi;
const SPAN = /\b(?:last|past)\s+(\d{1,6})\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|wks?|w)\b/gi;
/**
* A date with a NAMED month (`11 September`, `Sept 29`, `September 29, 2026`,
* `29th of Sep 2026`): the text fixes the order, so the parts are `date: fixed`
* with no year unless one is written — the year is the policy's (`year`) or the
* person's, as for `10/9`. The month alone (`in September`) is not read.
*/
const MONTH_DATE = new RegExp(`(?<![\\w:./-])(?:(${MONTHS})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`|(\\d{1,2})(?:st|nd|rd|th)?\\s+(?:of\\s+)?(${MONTHS})\\.?)` +
'(?:,?\\s+(\\d{4}))?(?![\\w:/]|\\.\\d)', 'gi');
/** `the last hour`, `the past week` — a look-back of one unit; without `the`, `last week` is a calendar week (not read). */
const ONE_SPAN = /\bthe\s+(?:last|past)\s+(hour|day|week)\b/gi;
/**
* A place named with the word `time` — `London time`, `New York time` — kept
* as written: which zone it names is `resolve.ts`'s (the tz database's one
* zone for the place, else asked). Capitalised words only, so `the same
* time` is no zone.
*/
const PLACE_TIME = '[A-Z][a-z]+(?:[ -][A-Z][a-z]+){0,2}\\s+[Tt]ime';
/** A zone right after a phrase: optional `(`, or `in`, then the token (and a closing `)`). */
const ZONE_AFTER = new RegExp('^(?<lead>\\s*\\(?\\s*|\\s+in\\s+)' +
`(?:(?<iana>(?:${IANA_AREAS})/[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)?)` +
'|(?:UTC|GMT)\\s?(?<offset>[+-]\\d{1,2}(?::?\\d{2})?)' +
`|(?<place>${PLACE_TIME})` +
`|(?<abbreviation>${ZONE_ABBREVIATIONS.join('|')}))(?![A-Za-z0-9_/])(?<close>\\s*\\))?`);
const num = (text) => text === undefined ? undefined : Number(text);
function wallOf(h, m, s, meridiem, clock) {
if (!Number.isInteger(h) || h > 23)
return undefined;
if (m !== undefined && m > 59)
return undefined;
if (s !== undefined && s > 59)
return undefined;
if (meridiem !== undefined && (h < 1 || h > 12))
return undefined;
return {
h,
...(m !== undefined && { m }),
...(s !== undefined && { s }),
...(meridiem !== undefined && { meridiem }),
...(clock !== undefined && { clock }),
};
}
function* matches(re, text) {
re.lastIndex = 0;
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
yield m;
if (m[0].length === 0)
re.lastIndex++;
}
}
function isoAtoms(text) {
const out = [];
for (const m of matches(ISO, text)) {
const [, y, mo, d, h, mi, s, zone] = m;
const date = { kind: 'fixed', year: Number(y), month: Number(mo), day: Number(d) };
// An ISO time is a 24-hour clock by its form: `T08:00` is 8 AM, never also 8 PM.
const wall = h === undefined ? undefined : wallOf(Number(h), Number(mi), num(s), undefined, '24h');
if (h !== undefined && wall === undefined)
continue;
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'instant',
date,
...(wall !== undefined && { wall }),
...(zone !== undefined && { zoneToken: zone }),
});
}
return out;
}
function numericAtoms(text) {
const out = [];
for (const m of matches(NUMERIC, text)) {
const [, a, b, c] = m;
if (a.length === 3 || (a.length === 4 && c === undefined))
continue;
const fields = [Number(a), Number(b), ...(c !== undefined ? [Number(c)] : [])];
const yearDigits = c === undefined ? undefined : a.length === 4 ? 4 : c.length;
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'date',
date: { kind: 'numeric', fields, ...(yearDigits !== undefined && { yearDigits }) },
});
}
return out;
}
function monthAtoms(text) {
const out = [];
for (const m of matches(MONTH_DATE, text)) {
const name = (m[1] ?? m[4]);
// `may` in lower case is the verb (`it may 3 times`), never a month.
if (name.toLowerCase() === 'may' && name !== 'May')
continue;
const month = MONTH_NUMBERS[name.slice(0, 3).toLowerCase()];
const day = Number(m[2] ?? m[3]);
if (month === undefined || day < 1 || day > 31)
continue;
const year = num(m[5]);
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'date',
date: { kind: 'fixed', month, day, ...(year !== undefined && { year }) },
});
}
return out;
}
function wallAtoms(text) {
const out = [];
for (const m of matches(WALL_MERIDIEM, text)) {
const meridiem = m[4].toLowerCase().startsWith('a') ? 'am' : 'pm';
const wall = wallOf(Number(m[1]), num(m[2]), num(m[3]), meridiem);
if (wall !== undefined) {
out.push({ start: m.index, end: m.index + m[0].length, kind: 'wall', wall });
}
}
for (const m of matches(WALL_COLON, text)) {
const wall = wallOf(Number(m[1]), Number(m[2]), num(m[3]));
if (wall !== undefined) {
out.push({ start: m.index, end: m.index + m[0].length, kind: 'wall', wall });
}
}
for (const m of matches(BARE_HOUR, text)) {
const wall = wallOf(Number(m[1]));
if (wall !== undefined) {
out.push({ start: m.index, end: m.index + m[0].length, kind: 'bare', wall });
}
}
return out;
}
function wordAtoms(text) {
const out = [];
for (const m of matches(DAY_WORD, text)) {
const offset = DAY_WORDS[m[1].toLowerCase()];
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'day',
relative: { unit: 'day', offset },
});
}
for (const m of matches(SPAN, text)) {
const count = Number(m[1]);
const unit = SPAN_UNITS[m[2].toLowerCase().replace(/s$/, '')];
if (unit === undefined || count < 1 || count > 100_000)
continue;
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'span',
relative: { unit, count },
});
}
for (const m of matches(ONE_SPAN, text)) {
const unit = SPAN_UNITS[m[1].toLowerCase()];
out.push({
start: m.index,
end: m.index + m[0].length,
kind: 'span',
relative: { unit, count: 1 },
});
}
return out;
}
/** The v1 phrases, overlaps settled: the earlier, then the longer, wins. */
function atomsOf(text) {
const all = [
...isoAtoms(text),
...numericAtoms(text),
...monthAtoms(text),
...wallAtoms(text),
...wordAtoms(text),
];
all.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
const kept = [];
for (const atom of all) {
const last = kept[kept.length - 1];
if (last !== undefined && atom.start < last.end)
continue;
kept.push({ ...atom });
}
for (const atom of kept)
attachZone(text, atom);
// A zone written after a phrase may cover a phrase found on its own (`UTC-07:00` holds `07:00`).
const out = [];
for (const atom of kept) {
const last = out[out.length - 1];
if (last === undefined || atom.start >= last.end)
out.push(atom);
}
return out;
}
/**
* A zone written right after a date, a time or a day word joins that
* phrase, as written — `yesterday London time` is a London day, and reading
* `yesterday` alone would silently put it in the app's zone. A look-back
* (`last 2 hours`) takes none: it runs until now in every zone.
*/
function attachZone(text, atom) {
if (atom.kind === 'span' || atom.kind === 'bare')
return;
if (atom.zoneToken !== undefined)
return;
const m = ZONE_AFTER.exec(text.slice(atom.end));
if (m === null || m.groups === undefined)
return;
const { lead, iana, offset, place, abbreviation, close } = m.groups;
if (lead.includes('(') !== (close !== undefined))
return;
// The IANA name, else the offset after `UTC` / `GMT`, else the place, else the abbreviation — each verbatim.
const token = iana ?? offset ?? place ?? abbreviation;
atom.zoneToken = token;
atom.end += m[0].length;
}
function notReadSpans(text, atoms) {
const coveredWhole = coverage(atoms);
const out = [];
for (const re of NOT_READ) {
for (const m of matches(re, text)) {
const span = { start: m.index, end: m.index + m[0].length };
// A span a v1 phrase covers whole is that phrase (`last 40 minutes`), not a later row.
if (coveredWhole(span))
continue;
out.push(span);
}
}
return out;
}
/**
* Whether some span of `covering` holds `span` whole — `covering.some((a) =>
* a.start <= span.start && span.end <= a.end)` — answered by binary search:
* among the spans starting at or before `span.start`, the farthest end. Asking
* `some` once per match cost matches × phrases, and a long message has many of
* both.
*/
function coverage(covering) {
const byStart = [...covering].sort((a, b) => a.start - b.start);
const starts = byStart.map((s) => s.start);
const farthestEnd = [];
let farthest = -Infinity;
for (const s of byStart)
farthestEnd.push((farthest = Math.max(farthest, s.end)));
return (span) => {
let after = 0;
let past = starts.length;
while (after < past) {
const mid = (after + past) >>> 1;
if (starts[mid] <= span.start)
after = mid + 1;
else
past = mid;
}
return after > 0 && farthestEnd[after - 1] >= span.end;
};
}
exports.coverage = coverage;
/** `\s`, asked of one code unit at a time — the regex's own class, so it cannot drift. */
const BLANK = /\s/;
/**
* `/\bbetween\s+$/i.test(text.slice(0, end))` — `between`, then blanks right
* up to `end` — walked back from `end`. The regex read the whole text before
* each phrase it was asked about, so a message of many short phrases cost its
* length times their number.
*/
function saysBetweenBefore(text, end) {
let word = end;
while (word > 0 && BLANK.test(String.fromCharCode(text.charCodeAt(word - 1))))
word--;
if (word === end || word < 7)
return false;
const spelled = 'between';
for (let i = 0; i < spelled.length; i++) {
if ((text.charCodeAt(word - 7 + i) | 0x20) !== spelled.charCodeAt(i))
return false;
}
return word === 7 || !isAsciiWordCode(text.charCodeAt(word - 8));
}
exports.saysBetweenBefore = saysBetweenBefore;
/**
* The longest phrase `MODIFIER_BEFORE`, `BARE_SIDE_BEFORE` or `FROM_BEFORE`
* matches spans three words (`no later than`, `from 8 to`, `between 8 and`);
* one spare.
*/
const WORDS_BEFORE = 4;
/**
* Where the text before `end` that those three patterns can reach starts: back
* over the blanks before `end`, then over `WORDS_BEFORE` words and the blanks
* before each. Each pattern ends at `end` (`\s*$`), spans fewer words, and
* takes its leading boundary from the character just before its first word —
* all inside this window, which starts at a blank or at 0, so the window's
* `^` adds no match. The leftmost match in the window is therefore the leftmost
* match in all the text before `end`; matching that text instead re-read the
* whole message once per group.
*/
function windowBefore(text, end) {
let at = end;
while (at > 0 && isBlankAt(text, at - 1))
at--;
for (let word = 0; word < WORDS_BEFORE && at > 0; word++) {
while (at > 0 && !isBlankAt(text, at - 1))
at--;
while (at > 0 && isBlankAt(text, at - 1))
at--;
}
return at;
}
exports.windowBefore = windowBefore;
function isBlankAt(text, at) {
return BLANK.test(String.fromCharCode(text.charCodeAt(at)));
}
/** `\w`: ASCII letters, digits, `_`. */
function isAsciiWordCode(code) {
const lower = code | 0x20;
return (code >= 0x30 && code <= 0x39) || (lower >= 0x61 && lower <= 0x7a) || code === 0x5f;
}
// ─── Mentions ────────────────────────────────────────────────────────────
/** A gap token that is one of the grammar's range connectors. */
const CONNECTOR = new RegExp(`^${RANGE_CONNECTOR}$`, 'i');
const GLUE = /^(?:,|at|on|in|of|the|@|from|between)$/i;
/** The words and marks between two phrases. */
const gapTokens = (gap) => gap.match(/\.\.|[–—\-/,@()]|[A-Za-z]+|\d+|\S/g) ?? [];
/** Whether the text between two phrases only joins them — and whether it says "range". */
function joins(gap, between, left, right) {
if (gap.replace(/[\s\w,@()./–—-]/g, '') !== '')
return undefined;
const tokens = gapTokens(gap);
let range = false;
for (const t of tokens) {
const instants = left.atom?.kind === 'instant' && right.atom?.kind === 'instant';
if (CONNECTOR.test(t) || t === '..' || (instants && t === '/')) {
if (range)
return undefined;
range = true;
}
else if (t.toLowerCase() === 'and' && between) {
if (range)
return undefined;
range = true;
}
else if (!GLUE.test(t)) {
return undefined;
}
}
return range ? 'range' : 'glue';
}
function groupsOf(text, atoms, notRead) {
const items = [
...atoms.map((atom) => ({ start: atom.start, end: atom.end, atom })),
...notRead.map((s) => ({ start: s.start, end: s.end })),
].sort((a, b) => a.start - b.start || b.end - a.end);
// Asked for the same group start once per item, so each position is read once.
const betweenAt = new Map();
const saysBetween = (end) => {
let says = betweenAt.get(end);
if (says === undefined)
betweenAt.set(end, (says = saysBetweenBefore(text, end)));
return says;
};
const groups = [];
for (const item of items) {
const group = groups[groups.length - 1];
const last = group?.items[group.items.length - 1];
if (group !== undefined && last !== undefined) {
if (item.start < group.end) {
group.items.push(item);
group.end = Math.max(group.end, item.end);
group.unreadable = true;
continue;
}
// `between` before the group, or right before its last phrase — the range's first side:
// `today between 1 pm and 2 pm`, `yesterday between 8 and 9 AM` (a day said first).
const between = saysBetween(group.start) || saysBetween(last.start);
const joined = joins(text.slice(group.end, item.start), between, last, item);
if (joined !== undefined) {
if (joined === 'range') {
if (group.rangeAt !== undefined)
group.unreadable = true;
group.rangeAt = group.items.length;
}
group.items.push(item);
group.end = item.end;
if (item.atom === undefined)
group.unreadable = true;
continue;
}
}
groups.push({
start: item.start,
end: item.end,
items: [item],
unreadable: item.atom === undefined,
});
}
for (const group of groups)
widenForModifiers(text, group);
return mergeOverlaps(groups).filter((g) => g.unreadable || g.items.some((i) => i.atom?.kind !== 'bare'));
}
/** Groups a widening made overlap are one phrase (`8 and 9 AM` held the bare `8` on its own). */
function mergeOverlaps(groups) {
const out = [];
for (const group of [...groups].sort((a, b) => a.start - b.start)) {
const last = out[out.length - 1];
if (last !== undefined && group.start < last.end) {
last.items.push(...group.items);
last.end = Math.max(last.end, group.end);
last.unreadable = true;
continue;
}
out.push(group);
}
return out;
}
/** A modifier before or after a v1 phrase changes it: the whole phrase is not read. */
function widenForModifiers(text, group) {
// Only the few words before the group can match (`windowBefore`).
const from = windowBefore(text, group.start);
const head = text.slice(from, group.start);
const clock = group.items.some((i) => i.atom?.wall !== undefined);
const startsOnWall = group.items[0]?.atom?.kind === 'wall';
const before = (startsOnWall ? exports.BARE_SIDE_BEFORE.exec(head) : null) ??
exports.MODIFIER_BEFORE.exec(head) ??
(group.rangeAt === undefined && clock ? exports.FROM_BEFORE.exec(head) : null);
if (before !== null) {
group.start = from + before.index + before[0].lastIndexOf(before[1]);
group.unreadable = true;
}
const after = MODIFIER_AFTER.exec(text.slice(group.end));
if (after !== null) {
group.end += after[0].length;
group.unreadable = true;
}
}
/** One side's parts from its phrases — `undefined` when two phrases say the same part. */
function sideOf(atoms) {
const side = {};
for (const a of atoms) {
if (a.date !== undefined || a.kind === 'day') {
if (side.date !== undefined || side.relative !== undefined)
return undefined;
if (a.date !== undefined)
side.date = a.date;
if (a.kind === 'day')
side.relative = a.relative;
side.dayFirst = side.wall === undefined;
}
if (a.kind === 'span') {
if (side.relative !== undefined)
return undefined;
side.relative = a.relative;
side.span = true;
}
if (a.wall !== undefined) {
if (side.wall !== undefined)
return undefined;
side.wall = a.wall;
if (a.kind === 'bare')
side.bare = true;
}
if (a.zoneToken !== undefined) {
if (side.zoneToken !== undefined)
return undefined;
side.zoneToken = a.zoneToken;
}
}
if (side.span === true && (side.date !== undefined || side.wall !== undefined))
return undefined;
return side;
}
const partsOfSide = (side) => ({
...(side.date !== undefined && { date: side.date }),
...(side.wall !== undefined && { wall: side.wall }),
...(side.zoneToken !== undefined && { zoneToken: side.zoneToken }),
...(side.relative !== undefined && { relative: side.relative }),
});
/** The parts a group says, or `undefined` when they are not a v1 phrase. */
function partsOf(group) {
const atoms = group.items.map((i) => i.atom);
if (group.rangeAt === undefined) {
const side = sideOf(atoms);
if (side === undefined || side.bare === true)
return undefined;
const parts = partsOfSide(side);
return (0, reader_js_1.isTimeParts)(parts) ? parts : undefined;
}
const left = sideOf(atoms.slice(0, group.rangeAt));
const right = sideOf(atoms.slice(group.rangeAt));
if (left === undefined || right === undefined)
return undefined;
if (left.span === true || right.span === true || right.bare === true)
return undefined;
if (left.bare === true && right.wall?.meridiem === undefined)
return undefined;
const outer = {};
// A day said before the first side's time, and not again, is the whole mention's…
const leftDay = left.date !== undefined || left.relative !== undefined;
const rightDay = right.date !== undefined || right.relative !== undefined;
if (leftDay && !rightDay && left.dayFirst === true && left.wall !== undefined) {
if (left.date !== undefined)
outer.date = left.date;
if (left.relative !== undefined)
outer.relative = left.relative;
delete left.date;
delete left.relative;
}
// …and so is a zone said after the second side, and not on the first.
if (right.zoneToken !== undefined && left.zoneToken === undefined) {
outer.zoneToken = right.zoneToken;
delete right.zoneToken;
}
const l = partsOfSide(left);
const r = partsOfSide(right);
if (Object.keys(l).length === 0 || Object.keys(r).length === 0)
return undefined;
const parts = { ...outer, rangeOf: [l, r] };
return (0, reader_js_1.isTimeParts)(parts) ? parts : undefined;
}
/** The text's mentions, in the order written — at most `MAX_MENTIONS`, the port's bound. */
function mentionsOf(text) {
const atoms = atomsOf(text);
const groups = groupsOf(text, atoms, notReadSpans(text, atoms));
const mentions = [];
for (const group of groups) {
const quote = text.slice(group.start, group.end);
if (quote.trim() === '')
continue;
const parts = group.unreadable ? undefined : partsOf(group);
mentions.push(parts === undefined
? { quote, parses: [], problem: 'unreadable' }
: { quote, parses: [parts] });
}
return mentions.slice(0, reader_js_1.MAX_MENTIONS);
}
/**
* The library's careful English reader: a `kind: 'rule'` tokenizer over the
* v1 phrases of the time design's § 5.3, "unreadable" for every other time
* phrase it recognises. No dependency; deterministic over the text.
*
* @example
* ```ts
* Agent.create({ provider, model })
* .time({ zone: 'America/Los_Angeles', reader: englishTimeReader() })
* .build();
* ```
*/
function englishTimeReader(options = {}) {
return Object.freeze({
id: exports.ENGLISH_TIME_READER_ID,
version: exports.ENGLISH_TIME_READER_VERSION,
locale: options.locale ?? 'en-US',
kind: 'rule',
read: (text) => ({ mentions: mentionsOf(text) }),
});
}
exports.englishTimeReader = englishTimeReader;
//# sourceMappingURL=english.js.map