agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
127 lines (126 loc) • 8.01 kB
TypeScript
/**
* 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 { type PeriodFacts } from './periodForm.js';
import { type InstantText } from './instant.js';
import { type ZoneName } from './zone.js';
/** What a time field's answer is (time design § 6.1). The value stays a string on the wire. */
export type TimeFormat = 'instant' | 'time-range' | 'zone';
export declare const TIME_FORMATS: readonly TimeFormat[];
/** Whether `value` names a {@link TimeFormat}. */
export declare function isTimeFormat(value: unknown): value is TimeFormat;
/** Why a time answer was refused — a code; the sentence is the catalog's. */
export type TimeAnswerProblem = 'not-an-instant' | 'no-offset' | 'not-a-range' | 'out-of-order' | 'dst-gap' | 'not-a-zone' | 'time-future' | 'time-past' | 'beyond-retention' | 'over-max-range';
/** One refused answer: the code and the facts its sentence names (the value as given, capped). */
export interface TimeAnswerRefusal {
readonly problem: TimeAnswerProblem;
readonly facts: Readonly<Record<string, string>>;
}
export declare const quoted: (value: string) => string;
/** A tool's declared facts and the turn's clock — what a `time-range` answer for that tool's period is judged against. */
export interface ToolTimeFacts {
readonly facts: PeriodFacts;
readonly now: InstantText;
}
/**
* 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 declare function checkTimeAnswer(format: TimeFormat, value: string, zone?: ZoneName, tool?: ToolTimeFacts): TimeAnswerRefusal | undefined;
/**
* 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 declare const TIME_ASK_MESSAGE_KEYS: readonly ["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"];
export type TimeAskMessageKey = (typeof TIME_ASK_MESSAGE_KEYS)[number];
/** A whole catalog: one sentence per key. */
export type TimeAskMessages = Readonly<Record<TimeAskMessageKey, string>>;
/**
* 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 declare function readTimeAskMessages(value: unknown): {
readonly value: Partial<TimeAskMessages>;
} | {
readonly problem: string;
};
/** A catalog sentence with its `{{name}}` placeholders filled; a placeholder with no fact is left as written. */
export declare function fillMessage(template: string, facts: Readonly<Record<string, string>>): string;
/**
* 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 declare function refusalReason(refusals: readonly TimeAnswerRefusal[], messages: TimeAskMessages): string;