agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
212 lines (211 loc) • 9.8 kB
TypeScript
/**
* time/clock — the run clock: a declared, recorded input, never a hidden read.
*
* Pattern: normalize-or-refuse at a boundary (`core/runInput.ts`'s shape): the
* app's `.time({ zone })` and the run's `time: { now, zone, window }`
* are read here into one {@link TimeClock}, or refused by name.
* Role: L0 leaf (the time layer's one owner). Imports `instant.ts`,
* `zone.ts`, `range.ts`, `reader.ts` (the reader's check) and
* `resolve.ts` (the policy's) only. The Agent reads the two inputs
* (`Agent.run`, `Agent.resume`); seed stamps the clock once per turn
* (`rows.ts` · `ClockRow`); the ToolCalls resume door compares a
* resume's `time` with the kept clock ({@link clockChange}).
* Emits: N/A.
*
* ## The laws (time design § 4)
*
* - **The zone is per run; the builder's is a fallback.** The run's
* `time.zone` wins, `.time({ zone })` is used when the run names none, and
* with neither the zone is UNKNOWN (G15) — never the server's zone, never a
* guess: the clock records `zoneSource: 'unknown'` and spells instants in
* {@link UNKNOWN_ZONE_SPELLING} (UTC), every reading of the person's words
* waits on their zone (it is asked first), and the zone they answer holds
* for the turns after it (`zoneSource: 'answered'`).
* - **`now` is the app's, else the turn's start** — admitted as
* `nowSource: 'default'`, like any default nobody chose.
* - **Frozen across a pause.** The clock is written once per turn. A resume
* that passes a different `time` keeps the frozen clock and records what was
* passed ({@link clockChange}); it is not refused, because an app that
* passes the current time on every call is doing nothing wrong.
* - **A window set in a UI is a run input** (`time.window`), recorded with
* `source: 'control'` — the person's own typed value, never filed as an
* answer to an ask.
*
* @example
* ```ts
* const run = readRunTime({ now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles' });
* const draft = draftClock(run.value, readTimeOptions({}).value!);
* // { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles', zoneSource: 'run' }
* completeClock(draft as ClockDraft, Date.parse('2026-10-09T15:40:02Z'));
* // { now: '2026-10-09T15:40:00Z', nowSource: 'app', zone: 'America/Los_Angeles', zoneSource: 'run' }
* ```
*/
import { type InstantText } from './instant.js';
import { type TimeRange } from './range.js';
import { type ZoneName } from './zone.js';
import { type TimeReader } from './reader.js';
import { type TimePolicy } from './resolveRecord.js';
import { type TimeAskMessages } from './ask.js';
/** The run's clock, stamped once per turn (time design § 4). */
export interface TimeClock {
/** The anchor for this turn — the app's `now`, else the turn's start. */
readonly now: InstantText;
/** The app passed `now`, or the library took the turn's start. */
readonly nowSource: 'app' | 'default';
/**
* The person's zone for this run — an IANA name. Under `zoneSource:
* 'unknown'` it is `'UTC'`: the zone instants are SPELLED in, never the
* person's.
*/
readonly zone: ZoneName;
/**
* The run's `time.zone`, else the `.time({ zone })` fallback; with neither,
* the zone the person answered in an earlier turn of this conversation
* (`'answered'`), else `'unknown'` (G15).
*/
readonly zoneSource: 'run' | 'builder' | 'answered' | 'unknown';
}
/**
* The zone a clock with no known zone spells its instants in (G15) — named
* as unknown wherever it is shown, never read as the person's.
*/
export declare const UNKNOWN_ZONE_SPELLING: string;
/**
* The run's time input — `agent.run({ message, time })`, or the options bag of
* `run`, `followUp`, `resume` and `resumeOnError`. Every key is optional; a
* run with no zone here needs the builder's fallback.
*
* @example
* ```ts
* await agent.run({ message: 'errors since 8?', time: { now: sentAt, zone: 'America/Los_Angeles' } });
* ```
*/
export interface RunTime {
/**
* The turn's anchor — the MESSAGE's time, so a replay or a late resume
* reads "yesterday" the same way. An ISO 8601 instant with a zone (upper
* case `T` and `Z`, no leap second), or a `Date`. Omitted: the turn's start,
* recorded `nowSource: 'default'`.
*/
readonly now?: string | Date;
/** The person's IANA zone for this run (`'America/Los_Angeles'`). Never an abbreviation. */
readonly zone?: string;
/**
* A window set in a UI — a brushed chart range, a range picker — as a
* half-open `{ from, to }` of instants. Recorded with `source: 'control'`.
*/
readonly window?: TimeRange;
}
/** The builder's `.time(options)` — the fallback zone, the reader and its policy (time design § 11). */
export interface TimeOptions {
/**
* The fallback zone for a run that names none. Omitted with no run zone
* either: the zone is unknown — never the server's — and the person is
* asked it before any time they wrote is read (G15).
*/
readonly zone?: string;
/**
* The strategy that reads the person's words into time parts (`TimeReader`).
* No default: without one, the person's words are not read — no
* `time-reading` row is filed.
*/
readonly reader?: TimeReader;
/**
* How the library picks among a reading's candidates (needs `reader`).
* `dateOrder` (default `'ask'`): a numeric date's orders become choices,
* or the one order your people write (`'MDY'`, `'DMY'`, `'YMD'`), recorded
* as assumed. `year` (default `'ask'`): a date said without a year is
* asked, or `'current'` — the clock's year, recorded as assumed.
*/
readonly policy?: {
readonly dateOrder?: TimePolicy['dateOrder'];
readonly year?: TimePolicy['year'];
};
/**
* Your words for the time ask (`defaultTimeAskMessages` keys → sentence):
* the reason a refused time answer is re-asked with, the ask's questions,
* the label on a reading to confirm. A key you leave out keeps the default.
*/
readonly messages?: Partial<TimeAskMessages>;
}
/** {@link RunTime} as read: every value checked and kept as written, a `Date` spelled in UTC. */
export interface ReadRunTime {
readonly now?: InstantText;
readonly zone?: ZoneName;
readonly window?: TimeRange;
}
/** {@link TimeOptions} as read — the policy's defaults filled in when a reader is armed. */
export interface ReadTimeOptions {
readonly zone?: ZoneName;
readonly reader?: TimeReader;
readonly policy?: TimePolicy;
/** The app's catalog overrides — every key one the catalog has. */
readonly messages?: Partial<TimeAskMessages>;
}
/** What a turn's clock is before seed knows the turn's start. */
export interface ClockDraft {
readonly now?: InstantText;
readonly zone: ZoneName;
/** `'unknown'`: neither the run nor the builder named a zone — `zone` is the UTC spelling. */
readonly zoneSource: 'run' | 'builder' | 'unknown';
readonly window?: TimeRange;
}
/** A read answer: the value, or a problem named in words. */
export type Read<T> = {
readonly value: T;
} | {
readonly problem: string;
};
/**
* The run's `time` input, checked. `undefined` reads as "nothing passed".
* Refused, by name: a non-object, an unknown key, a zone that is not an IANA
* name, a `now` that is not an instant with a zone, a `window` that is not two
* strict instants with `from` before `to`.
*/
export declare function readRunTime(value: unknown): Read<ReadRunTime | undefined>;
/**
* The builder's `.time(options)`, checked. `undefined` reads as `{}` — the
* layer armed with no fallback zone, so every run names its own.
*/
export declare function readTimeOptions(value: unknown): Read<ReadTimeOptions>;
/**
* The turn's clock as far as the run's inputs decide it. With no zone from
* the run or the builder the zone is UNKNOWN (G15): `zoneSource: 'unknown'`,
* instants spelled in {@link UNKNOWN_ZONE_SPELLING} — never the server's
* zone, never a guess (seed reads a zone the person answered in an earlier
* turn, `stages/timeLayer.ts` · `stampClock`).
*/
export declare function draftClock(run: ReadRunTime | undefined, options: ReadTimeOptions): ClockDraft;
/**
* The clock, once the turn's start is known: `now` is the app's, else that
* start. `answeredZone` — the zone the person answered in an earlier turn —
* is read only when the draft's zone is unknown, and is then the turn's zone
* (`zoneSource: 'answered'`).
*/
export declare function completeClock(draft: ClockDraft, turnStartMs: number, answeredZone?: ZoneName): TimeClock;
/** What a resume passed that differs from the kept clock, and the kept values it would have replaced. */
export interface ClockChange {
readonly passed: ReadRunTime;
readonly kept: {
readonly now: InstantText;
readonly zone: ZoneName;
readonly window?: TimeRange;
};
}
/**
* The difference between a resume's `time` and the turn's frozen clock, or
* `undefined` when every value passed equals the kept one (a key not passed
* is no difference). `now` compares as text: a resume passing the same
* instant in another spelling is recorded, because the record keeps spellings.
*
* @example
* ```ts
* clockChange({ now: '2026-10-09T16:10:00Z' }, { now: '2026-10-09T15:40:00Z', zone: 'UTC' });
* // { passed: { now: '2026-10-09T16:10:00Z' }, kept: { now: '2026-10-09T15:40:00Z', zone: 'UTC' } }
* ```
*/
export declare function clockChange(passed: ReadRunTime, kept: {
readonly now: InstantText;
readonly zone: ZoneName;
readonly window?: TimeRange;
}): ClockChange | undefined;