agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
231 lines • 11.6 kB
JavaScript
/**
* time/ask — the one time ask (time design § 6): what a time field's
* answer must be, why one is refused, and the choices a reading offers.
*
* Pattern: one judge per boundary (the `range.ts` precedent). A typed ask's
* `InputField.format` (`tools/inputRequest.ts`) names what the answer
* is; {@link checkTimeAnswer} is the ONE judge of it — at definition
* (a choice, a supplied value) and at the resume door (the person's
* answer). A refusal is a CODE with its facts; the sentence is a
* catalog entry (`src/locales/timeAsk.ts` · `defaultTimeAskMessages`)
* the app may override (`.time({ messages })`), filled here
* ({@link refusalReason}) — the library writes a reason only for a
* check the app armed by declaring the field (TQ7).
* Role: L0 leaf (the time layer). Imports `instant.ts`, `zone.ts`,
* `range.ts`, `present.ts` and the row / candidate TYPES only.
* Emits: N/A.
*
* ## What an answer must be (§ 6.1, § 6.2)
*
* | `format` | The answer | Refused as |
* |----------|------------|------------|
* | `instant` | an ISO 8601 date-time WITH its offset, strict profile (`2026-10-09T08:00-07:00`) | `not-an-instant`; `no-offset` (a date-time with no zone); `dst-gap` |
* | `time-range` | an ISO 8601 interval `from/to` of two such instants, `from` before `to` | `not-a-range`; either end's refusal; `out-of-order` |
* | `zone` | an IANA zone name (`America/Los_Angeles`) — never an abbreviation or a bare offset | `not-a-zone` |
* | `time-range`, for a tool's period | the same, inside the tool's declared facts | `time-future`; `time-past`; `beyond-retention`; `over-max-range` |
*
* A `time-range` answer given for a tool's period is also judged against the
* tool's declared FACTS (§ 6.2, step T5a — `convert.ts` ·
* `periodFactProblem`), when the caller hands them with the turn's clock:
* `time-future` (a `past` source asked for a window after now), `time-past`
* (a `future` source asked for one that ended), `beyond-retention` (the WHOLE
* window older than the source keeps — a partial overlap is taken) and
* `over-max-range` (wider than the source reads at once).
*
* `dst-gap` is asked only when the ask knows the person's zone (the run
* clock's, under `.time()`): a wall time that zone's clocks skip, written with
* one of the two offsets around the change (`2026-03-08T02:30-08:00` in Los
* Angeles) names an instant nobody's clock showed. An instant written in some
* other offset is that instant, and is taken.
*
* ## The choices a reading offers (§ 6.1, § 6.3)
*
* `readingAsk.ts` · `timeAskOf` (split out: it renders labels through
* `present.ts`, which only an armed agent loads) turns a `time-reading` row whose choice is `open` into one
* field: a `format: 'zone'` field when the person named a zone the layer
* cannot read (`PST`), else a `format: 'time-range'` field whose `enum` is
* the candidates left (each an ISO interval) and whose `labels` render each in
* the reader's locale, the zone named, with the end the person said. A
* `kind: 'model'` reader's window is offered as the LIBRARY'S reading to
* confirm ("I read “yesterday” as … — is that right?"), never as the person's
* words (§ 5.5). So is EVERY `rule` reading (the owner's decision "Always
* confirm", time design TQ29 — `rowsBuild.ts` · `timeReadingRows`): the choice is
* the reading pre-filled WITH ITS ZONE ("I read “yesterday” as Thu, Oct 8,
* 2026, PDT in America/Los_Angeles — is that right?"), so a person who meant
* another zone's day corrects it in one answer. Free entry stays open: a
* field is `strict` only when the app says so.
*
* @example
* ```ts
* checkTimeAnswer('time-range', '2026-10-09T08:40-07:00/2026-10-09T08:00-07:00');
* // { problem: 'out-of-order', facts: { from: '2026-10-09T08:40-07:00', to: '2026-10-09T08:00-07:00' } }
* checkTimeAnswer('instant', '2026-10-09T08:00'); // { problem: 'no-offset', facts: { value: '2026-10-09T08:00' } }
* checkTimeAnswer('instant', '2026-03-08T02:30-08:00', 'America/Los_Angeles'); // { problem: 'dst-gap', … }
* checkTimeAnswer('zone', 'PST'); // { problem: 'not-a-zone', facts: { value: 'PST' } }
* refusalReason([checkTimeAnswer('zone', 'PST')!], defaultTimeAskMessages);
* // '“PST” is not a time zone name. Name one such as America/Los_Angeles.'
* ```
*/
import { periodFactProblem } from './periodForm.js';
import { compareInstants, instantOf, utcWallMs } from './instant.js';
import { isZoneName, readWall } from './zone.js';
export const TIME_FORMATS = Object.freeze(['instant', 'time-range', 'zone']);
/** Whether `value` names a {@link TimeFormat}. */
export function isTimeFormat(value) {
return TIME_FORMATS.includes(value);
}
/** A date-time a person wrote with no offset: it could be any zone's. */
const NO_OFFSET = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
const WALL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
/** A quoted value in a sentence is cut here: a reason names the answer, it does not repeat an essay. */
const QUOTE_CHARS = 64;
export const quoted = (value) => value.length <= QUOTE_CHARS ? value : `${value.slice(0, QUOTE_CHARS - 1)}…`;
/**
* Whether `value` writes a wall time the clocks of `zone` skip, with one of
* the two offsets around that change — an instant no clock in the zone showed.
*/
function inDstGap(value, offsetMinutes, zone) {
const m = WALL.exec(value);
if (m === null)
return false;
const [year, month, day, hour, minute] = [m[1], m[2], m[3], m[4], m[5]].map(Number);
const second = m[6] === undefined ? 0 : Number(m[6]);
const reading = readWall({ year, month, day, hour, minute, second }, zone);
if (reading.kind !== 'gap')
return false;
const wall = utcWallMs(year, month, day, hour, minute, second);
const offsetMs = offsetMinutes * 60_000;
// `readWall` names the gap's two readings: `earlier` with the offset AFTER the change, `later` with the one before.
return offsetMs === wall - reading.earlier || offsetMs === wall - reading.later;
}
function checkInstant(value, zone) {
const instant = instantOf(value, 'strict');
if (instant === undefined) {
return {
problem: NO_OFFSET.test(value) ? 'no-offset' : 'not-an-instant',
facts: { value: quoted(value) },
};
}
if (zone !== undefined && inDstGap(value, instant.offsetMinutes, zone)) {
return {
problem: 'dst-gap',
facts: { value: quoted(value), wall: value.slice(0, 16).replace('T', ' '), zone },
};
}
return undefined;
}
/**
* Why `value` is not a well-formed answer of `format`, or `undefined` when it
* is one. `zone` — the person's zone, when the ask knows it (the run clock's)
* — arms the DST-gap check; without it the answer is judged for shape, order
* and offset only. `tool` — a tool's declared facts and the clock — judges a
* well-formed `time-range` against them too.
*/
export function checkTimeAnswer(format, value, zone, tool) {
const known = zone !== undefined && isZoneName(zone) ? zone : undefined;
switch (format) {
case 'zone':
return isZoneName(value)
? undefined
: { problem: 'not-a-zone', facts: { value: quoted(value) } };
case 'instant':
return checkInstant(value, known);
case 'time-range': {
const halves = value.split('/');
if (halves.length !== 2)
return { problem: 'not-a-range', facts: { value: quoted(value) } };
const [from, to] = halves;
const refused = checkInstant(from, known) ?? checkInstant(to, known);
if (refused !== undefined)
return refused;
const a = instantOf(from, 'strict');
const b = instantOf(to, 'strict');
if (a === undefined || b === undefined || compareInstants(a, b) >= 0) {
return { problem: 'out-of-order', facts: { from: quoted(from), to: quoted(to) } };
}
if (tool === undefined)
return undefined;
const problem = periodFactProblem({ from, to }, tool.facts, tool.now);
if (problem === undefined)
return undefined;
return {
problem,
facts: {
from: quoted(from),
to: quoted(to),
...(tool.facts.retention !== undefined && { retention: tool.facts.retention }),
...(tool.facts.maxRange !== undefined && { maxRange: tool.facts.maxRange }),
},
};
}
}
}
// ─── The catalog ─────────────────────────────────────────────────────────
/**
* Every sentence the time ask can put before a person — the keys of the
* catalog (`src/locales/timeAsk.ts`). `answer.*` is a refusal's reason, one
* per {@link TimeAnswerProblem}; `ask.*` a question; `choice.confirm` the
* label on a reading to confirm — every reading the armed reader made.
* Placeholders are `{{name}}`; the label also takes `zone`.
*/
export const TIME_ASK_MESSAGE_KEYS = Object.freeze([
'answer.not-an-instant',
'answer.no-offset',
'answer.not-a-range',
'answer.out-of-order',
'answer.dst-gap',
'answer.not-a-zone',
'answer.time-future',
'answer.time-past',
'answer.beyond-retention',
'answer.over-max-range',
'ask.which',
'ask.confirm',
'ask.zone',
'ask.zone-unknown',
'choice.confirm',
]);
const MAX_SENTENCE = 4096;
/**
* The app's overrides (`.time({ messages })`) read — every key one the
* catalog has, every value a non-blank sentence of at most 4096 characters —
* or a problem in words.
*/
export function readTimeAskMessages(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return { problem: 'messages must be an object of catalog key → sentence' };
}
const out = {};
for (const [key, sentence] of Object.entries(value)) {
if (!TIME_ASK_MESSAGE_KEYS.includes(key)) {
return {
problem: `messages has no key '${key}' — the keys are ${TIME_ASK_MESSAGE_KEYS.join(', ')}`,
};
}
if (typeof sentence !== 'string' ||
sentence.trim().length === 0 ||
sentence.length > MAX_SENTENCE) {
return {
problem: `messages['${key}'] must be a non-blank sentence of at most ${MAX_SENTENCE} characters`,
};
}
out[key] = sentence;
}
return { value: out };
}
/** A catalog sentence with its `{{name}}` placeholders filled; a placeholder with no fact is left as written. */
export function fillMessage(template, facts) {
return template.replace(/\{\{(\w+)\}\}/g, (whole, name) => Object.prototype.hasOwnProperty.call(facts, name) ? facts[name] : whole);
}
/**
* The reason a re-ask carries (`InputRefusal.reason`): one catalog sentence
* per refused field, in field order, joined by a space — at most 4096
* characters, the refusal's own bound.
*/
export function refusalReason(refusals, messages) {
const reason = refusals
.map((r) => fillMessage(messages[`answer.${r.problem}`], r.facts))
.join(' ');
return reason.length <= MAX_SENTENCE ? reason : `${reason.slice(0, MAX_SENTENCE - 1)}…`;
}
//# sourceMappingURL=ask.js.map