/** * types.ts, the shapes of occasions, plans, acknowledgements and nudges. * * Two things live here, and they are deliberately not one thing (docs/occasions.md §2): * * - An **occasion** is dated, usually recurring, and NEEDS AN ACTION. A birthday, * an anniversary. It prompts, and the answer is remembered. * - A **plan** is a dated range with attributes and is AMBIENT. "Vacation, * 12–19 September, Lisbon." There is nothing to decide; the system needs to * know so it can stop suggesting things into that window, and so it can move * a nudge that would otherwise land while the owner is away. * * Both are declarations the owner owns, so both live as prose lines in their * profile file. Nothing machine-written goes there, the acknowledgement state * below is a separate machine-owned store, because the profile design's whole * guarantee is that a validator never rewrites a line they wrote. */ import type { IsoDate, OccasionDate, OccasionRecurrence } from './dates.js'; export type { IsoDate, OccasionDate, OccasionRecurrence } from './dates.js'; /** * What an occasion wants, chosen by the owner at capture and NEVER inferred. * * The reason this is not a guess: a parent's death anniversary is worth * remembering, and a cheerful "you'll probably want to sort something" against * it would be genuinely bad. There is no heuristic that gets that right from a * label, so there is no heuristic. * * - `gift-giving` , raise it, and a yes opens the gift interview. * - `remember-only`: raise it, and never mention a gift. * - `neither` , never raise it. It is recorded so the date can be * answered when the owner asks, and for nothing else. */ export type OccasionKind = 'gift-giving' | 'remember-only' | 'neither'; export declare const OCCASION_KINDS: readonly OccasionKind[]; export declare function isOccasionKind(value: string): value is OccasionKind; /** * Who the occasion is ABOUT, as distinct from who declared it. * * The owner does not need to be told when their own birthday is, and being told * hourly is worse than not being told at all. That rule needs a subject, so the * subject is modelled rather than guessed: * * - `owner` , it is about them. Either the line says so (`for me`), or its * attribution resolves to a name they declared for THEMSELVES * in `Identity` (`identity.name`, `identity.goesBy`). * - `other` , it is about a named someone else. * - `unattributed`, the line names nobody resolvable. "Our anniversary", "Dad". * Treated as normal, because guessing that an unattributed * line is about them is how their spouse's birthday goes quiet. * * There is no name literal anywhere in this module. The comparison is against * what THE OWNER'S FILE says their name is, so it is right on a machine * belonging to anyone, and it stays right when they change what they go by. */ export type OccasionSubject = 'owner' | 'other' | 'unattributed'; export declare const OCCASION_SUBJECTS: readonly OccasionSubject[]; /** * One occasion, as declared in the profile. * * `id` is derived from the title rather than minted, so the same line reloaded * after a hand edit is the same occasion and its acknowledgement state survives. * Editing the TITLE does orphan the state, and orphaned state is reaped, which * is the honest outcome: the owner renamed the thing, and last year's "no" was * about something with a different name. */ export interface Occasion { /** Normalised title. Stable across date, kind and lead edits. */ readonly id: string; /** The title exactly as written. */ readonly title: string; readonly date: OccasionDate; readonly recurrence: OccasionRecurrence; readonly kind: OccasionKind; /** The person it is about, as a plain label. Empty when the title carries it. */ readonly person: string; /** * True when the LINE ITSELF says this one is about the owner, `for me`, `mine`. * * Separate from {@link subject} because the grammar can see this and cannot * see the owner's name: the parser reads one line and knows nothing about the * `Identity` section. This is what the parser found; `subject` is what the * reader concluded once it also had their declared names in hand. */ readonly selfDeclared: boolean; /** * Who it is about, resolved. See {@link OccasionSubject}. * * `unattributed` until a reader with access to the owner's declared names * resolves it, the safe direction, because an unresolved subject gets the * ordinary cadence rather than silence. */ readonly subject: OccasionSubject; /** Per-occasion lead override in days, or `null` for the configured default. */ readonly leadDays: number | null; /** True when this occasion has been mirrored out to a calendar. */ readonly mirrored: boolean; /** Segments this module did not recognise, preserved so nothing is lost. */ readonly extras: readonly string[]; /** Index of the line in the profile document, for a surgical removal. */ readonly lineIndex: number; /** The line's text as written, minus its provenance suffix. */ readonly text: string; } /** * A line under the dates heading that did not parse as an occasion. * * Reported, never rewritten and never dropped, the same contract the profile's * mechanical fields have. A line the parser dislikes is still the owner's line. */ export interface UnparsedOccasionLine { readonly lineIndex: number; readonly text: string; readonly reason: string; } /** One plan: a dated range with attributes. Ambient; never prompts. */ export interface Plan { readonly id: string; readonly title: string; readonly from: IsoDate; readonly to: IsoDate; /** True when the owner is away from home for this plan. Feeds nudge timing. */ readonly away: boolean; /** Where, when the owner said. Empty when they did not. */ readonly destination: string; readonly extras: readonly string[]; readonly lineIndex: number; readonly text: string; } /** A line under the plans heading that did not parse. Same contract as above. */ export interface UnparsedPlanLine { readonly lineIndex: number; readonly text: string; readonly reason: string; } /** * Two declarations of the same occasion that disagree about the date. * * Raised immediately and re-raised if ignored. The newer value is NEVER taken * silently: the owner said two different things and only they know which was * right. */ export interface OccasionConflict { readonly occasionId: string; readonly title: string; /** Every distinct date declared for this occasion, in document order. */ readonly dates: readonly string[]; readonly lineIndexes: readonly number[]; } /** * What the owner answered when asked about an occasion. * * `acknowledged` is not a fourth flavour of `no`. The other three END the * question, a `no` and a `yes` resolve the open item and it is gone. This one * says only *"heard you"*: the item STAYS OPEN and stays enumerable, and what * changes is that nothing is pushed at the owner about this occurrence again. * That distinction is the whole point of having it. They answered a nudge * about their spouse's birthday with "yeah I know, I'm on it", that is not a * decline, it is not a yes that should open a gift interview, and it is * certainly not a reason to keep pinging them. */ export type OccasionAnswer = 'yes' | 'no' | 'later' | 'acknowledged'; export declare const OCCASION_ANSWERS: readonly OccasionAnswer[]; export declare function isOccasionAnswer(value: string): value is OccasionAnswer; /** * How an acknowledgement came to be recorded. * * Kept because "why did this go quiet" is a question the owner will ask, and * the three paths are genuinely different promises: one is a sentence in a * conversation, one is a button, and one is inferred from them working on the * gift. Recorded, never inferred after the fact. */ export type OccasionAckSource = 'conversation' | 'explicit' | 'gift-flow'; export declare const OCCASION_ACK_SOURCES: readonly OccasionAckSource[]; export declare function isOccasionAckSource(value: string): value is OccasionAckSource; /** * One recorded answer, for ONE occurrence. * * Keyed by occurrence rather than by occasion, which is what makes "declining * goes silent until the date passes, then asks fresh next year" a property of * the data rather than a rule someone has to remember to apply. Next year's * occurrence has no record, so next year asks, carrying no memory of the refusal. * * `expiresAfter` is the occurrence date for a recurring occasion and absent for * a one-off, where "handled" is permanent. */ export interface OccasionAcknowledgement { readonly id: string; readonly occasionId: string; readonly occurrence: IsoDate; readonly answer: OccasionAnswer; readonly answeredAt: number; /** Where the answer came from. Present on acknowledgements; absent elsewhere. */ readonly source?: OccasionAckSource | undefined; /** Absent ⇒ permanent. Present ⇒ reaped once this date has passed. */ readonly expiresAfter?: IsoDate | undefined; /** For `later`: the date the question comes back. */ readonly returnOn?: IsoDate | undefined; } /** * What the owner landed on, not merely that they said yes. * * Kept so year three does not steer where year one did. It outlives the * acknowledgement deliberately: the answer expires with its occurrence, the * history is the point. */ export interface GiftRecord { readonly occasionId: string; readonly occurrence: IsoDate; readonly recordedAt: number; /** What the owner settled on, in their words. */ readonly landedOn: string; readonly notes?: string | undefined; } /** * One occasion written out to a calendar, remembered so it is written once. * * The mirror is not the record and this is not a second source of truth: it * holds the external id of an entry this system CREATED, and it exists so the * mirror is idempotent, re-writing the same occasion each year must not * accumulate duplicates. Nothing reads a calendar to build an occasion, so * deleting the calendar entry does not delete anything here; the next mirror * pass simply writes it again. */ export interface OccasionMirrorRecord { readonly occasionId: string; readonly occurrence: IsoDate; /** The external calendar's own id for the entry this system created. */ readonly externalId: string; readonly mirroredAt: number; } /** Which unresolved thing an open item is. One mechanism, three cases. */ export type OpenItemKind = 'nudge' | 'conflict' | 'interview'; /** * The two moments a nudge is allowed to SPEAK, and the whole ceiling. * * - `lead` , the day the occasion entered its lead window. The runway. * - `day-of`, the occasion itself. * * There is no third. "Nothing unresolved drops" was read for a while as "raise * it again every pass until the owner answers", which on an hourly sweep meant * the owner was told about their own birthday five times in a day and counting. * It never meant that. It means the OPEN ITEM persists and stays enumerable until * it is resolved or expires, that is a property of the record, not a licence * to repeat the push. Between the two boundaries the item sits open and quiet: * ask "anything coming up?" and it is there; say nothing and it says nothing. * * The ceiling is enforced by RECORDING WHICH BOUNDARY A RAISE SERVED rather * than by counting raises or comparing timestamps. A boundary is served once, * and a served boundary is not raisable again, so the ceiling holds no matter * how often the sweep runs, whether the daemon restarts, or whether a clock * moves backwards. A count could be reset by a bad write; a set of served * boundaries has nothing to reset to. */ export type RaiseBoundary = 'lead' | 'day-of'; export declare const RAISE_BOUNDARIES: readonly RaiseBoundary[]; export declare function isRaiseBoundary(value: string): value is RaiseBoundary; /** The most times one occurrence may be pushed. Two, and it is structural. */ export declare const MAX_NUDGE_RAISES: number; /** * Something raised and not yet resolved. * * The governing principle is that nothing unresolved is ever dropped, and this * is the one mechanism behind all three of its cases: an unanswered nudge, a * conflict the owner ignored, and an interview they walked away from * mid-thread. Silence ends nothing; it only moves `dueAt`. */ export interface OpenItem { readonly id: string; readonly kind: OpenItemKind; /** The occasion this concerns. */ readonly occasionId: string; /** The occurrence, for a nudge or an interview. Empty for a conflict. */ readonly occurrence: IsoDate; readonly openedAt: number; readonly lastRaisedAt: number; readonly raiseCount: number; /** * The boundaries this item has already spoken at. Nudges only. * * The gate on a nudge, and the reason the two-raise ceiling cannot be lost: * the sweep asks which boundary TODAY is, and raises only if that boundary is * absent from this list. Empty for a conflict or an interview, which keep the * older repeating cadence, a conflict is a fact about the owner's FILE that * stays wrong until they fix it, and an interview is a conversation they * walked out of, and neither is the class of thing that was drowning them. */ readonly servedBoundaries: readonly RaiseBoundary[]; /** The calendar day this may be raised again. */ readonly dueOn: IsoDate; /** The occurrence this item dies with; absent ⇒ it lives until resolved. */ readonly expiresAfter?: IsoDate | undefined; /** * The day a push last landed this item in the agent's own conversation. * * Set only when a delivery to the `agent` destination actually succeeded, and * it is what stops the push and the pull speaking the same thing twice: while * the agent is a configured push destination, `occasions.pending` leaves out * an item that has already been landed there. Absent means no push has landed * on the agent, because none was configured, or because the one attempted * failed, and the pull is then how the item gets raised, so a failed push * never costs the owner the nudge. */ readonly agentPushedOn?: IsoDate | undefined; } /** One occasion inside a nudge. Carries the person; never carries the date. */ export interface NudgeSubject { readonly occasionId: string; readonly title: string; readonly person: string; readonly kind: OccasionKind; /** How close it is, as a word. Never a count of days and never a date. */ readonly proximity: 'approaching' | 'soon' | 'imminent'; /** Who it is about. Drives whether it may be pushed at all. */ readonly subject: OccasionSubject; /** * True when the owner has already said they have this one in hand. * * Carried on the PULL and never on a push, because an acknowledged occurrence * is not pushed. A surface listing what is coming up shows it and shows that * they acknowledged it; nothing sends it to them again. */ readonly acknowledged: boolean; } /** * A nudge, ready to render. * * `message` is the rendered text and `subjects` is the same content structured, * so a surface can lay it out its own way without re-deriving anything. Neither * carries a date: the date is a closed-tier read, disclosed only on an explicit * ask, and a reminder delivered to a message channel must not put a family * member's birth date into that channel. */ export interface OccasionNudge { readonly id: string; readonly raisedAt: number; readonly subjects: readonly NudgeSubject[]; readonly message: string; /** True when this batch invites a yes/no/later answer. */ readonly answerable: boolean; } /** One question in the gift interview, and why it is being asked. */ export interface InterviewStep { readonly id: string; readonly prompt: string; /** The profile prose this question was opened from, verbatim. Empty when none. */ readonly opensFrom: string; } /** An interview in progress, or one that was walked away from. */ export interface Interview { readonly id: string; readonly occasionId: string; readonly occurrence: IsoDate; readonly startedAt: number; readonly steps: readonly InterviewStep[]; /** Answers keyed by step id, in the order they were given. */ readonly answers: readonly InterviewAnswer[]; /** Set when the owner landed on something; the interview is then complete. */ readonly landedOn?: string | undefined; readonly completedAt?: number | undefined; } export interface InterviewAnswer { readonly stepId: string; readonly text: string; readonly answeredAt: number; } /** What the acknowledgement store discloses about itself. */ export interface OccasionStateDisclosure { readonly path: string; readonly acknowledgements: number; readonly giftRecords: number; readonly openItems: number; readonly interviews: number; readonly mirrors: number; /** Records dropped by the last sweep, by reason. */ readonly lastSweep: OccasionSweepReport | null; /** * Open items whose raise ledger was rebuilt when this file was loaded. * * The receipt for the migration off the old repeating cadence. A machine that * had been raising one occurrence every hour loads with items carrying a * raise count and no served boundaries; they are kept open, marked as having * already spoken, and counted here, so the machine going quiet is a stated * fact rather than a mystery. */ readonly reconciledOpenItems: number; /** Non-null when the file existed and could not be read. */ readonly corruption: string | null; } /** What a housekeeping pass removed, and why. */ export interface OccasionSweepReport { readonly sweptAt: number; /** Answers whose occurrence has passed, so next cycle asks fresh. */ readonly expiredAcknowledgements: number; /** State whose occasion is no longer declared in the profile. */ readonly orphanedRecords: number; /** Open items whose occurrence has passed unanswered. */ readonly expiredOpenItems: number; /** Gift records older than the configured retention. */ readonly agedGiftRecords: number; /** Interviews dropped with their occasion or occurrence. */ readonly droppedInterviews: number; /** Mirror records whose occurrence has passed or whose occasion is gone. */ readonly staleMirrors: number; } export declare const OCCASIONS_CONFIG_KEYS: { readonly enabled: "occasions.enabled"; readonly leadDays: "occasions.leadDays"; readonly activeHours: "occasions.activeHours"; readonly nudgeChannel: "occasions.nudgeChannel"; readonly cadenceDays: "occasions.cadenceDays"; readonly awayAdjust: "occasions.awayAdjust"; readonly calendarMirror: "occasions.calendarMirror"; readonly suppressMirroredNudges: "occasions.suppressMirroredNudges"; readonly interviewQuestions: "occasions.interviewQuestions"; readonly giftHistoryYears: "occasions.giftHistoryYears"; readonly sweepIntervalMinutes: "occasions.sweepIntervalMinutes"; }; /** The heading occasions are declared under. A canonical profile section. */ export declare const OCCASIONS_SECTION = "Important dates"; /** The heading plans are declared under. A canonical profile section. */ export declare const PLANS_SECTION = "Plans"; //# sourceMappingURL=types.d.ts.map