/** * service.ts, the one object every occasions verb goes through. * * It owns nothing it can compute: the profile is read through a narrow source, * the machine state through its own store, the clock through an injected * function and delivery through a seam. What it owns is the SEQUENCE, capture * confirms once, an answer resolves an open item, a yes opens an interview, a * completed interview writes gift history, a removal drops orphaned state. * * That sequence lives here rather than in a consumer on purpose. A surface's job * is to call a verb and render what comes back; anything a surface would have to * compute is a second implementation waiting to disagree with this one. * * ## The TUI is not a destination, structurally * * The owner's ruling was Telegram and the agent, never the TUI, because *"that's * more of a 'get work done' kind of interface."* `resolveNudgeDestinations` in * destinations.ts refuses a TUI target rather than merely not choosing one, so * the rule survives a composition root that wires the delivery router * optimistically. * * ## Telegram and the agent, and one thing said once * * `occasions.nudgeChannel` is a LIST, so the owner's ruling, Telegram and the agent, * is expressible rather than a choice between them. Each destination is pushed * independently and a failure on one is recorded rather than thrown, so a dead * Telegram token cannot silence the agent and a missing agent sender cannot * silence Telegram. * * The agent is also the surface that PULLS through {@link OccasionsService.pending}, * so it is the one place where a push and a pull could say the same thing twice. * They cannot, because both read the same open item: a push that lands on the * agent stamps the item with the DAY it landed, and the pull leaves out items * stamped with today. An item no push has ever landed there carries no stamp, * so the pull is still how that nudge gets raised, which covers `agent` * configured with no sender registered, and a send that failed. * * The stamp is scoped to the day rather than being permanent, and that matters * more than it used to. A nudge now pushes exactly twice; a permanent stamp * would have made an occasion invisible to "anything coming up?" for the whole * ten days after its first push, which is the opposite of what the pull is for. */ import type { AuthoritySurface } from '../security/untrusted-content.js'; import { type IsoDate } from './dates.js'; import { type OccasionAckSource } from './acknowledge.js'; import { type NudgeDelivery, type OccasionNudgeDeliverer } from './push.js'; import { type PendingResult as ComposedPending } from './pending.js'; import { type OccasionsConfigAccess } from './policy.js'; import { type OccasionProfileSource } from './reader.js'; import type { OccasionStateStore } from './state-store.js'; import { type OccasionsPolicy, type SweepHold } from './sweep.js'; import { type ConfirmOccasionInput, type ConfirmPlanInput, type OccasionProfileWriter, type OccasionProposal, type OccasionWriteOutcome, type ProposeOccasionInput, type ProposePlanInput } from './capture.js'; import type { GiftRecord, InterviewStep, Occasion, OccasionAnswer, OccasionConflict, OccasionNudge, OccasionStateDisclosure, OccasionSweepReport, Plan, UnparsedOccasionLine, UnparsedPlanLine } from './types.js'; /** * The delivery seam and its per-destination result, re-exported at the seam * every consumer already imports from. They live in push.ts; a caller wiring up * a deliverer should not have to know which file the sweep was split into. */ export type { NudgeDelivery, OccasionNudgeDeliverer } from './push.js'; /** * Writing an occasion out to a calendar. Optional, and one-directional. * * There is no read counterpart and there never will be: a calendar entry is a * single occurrence of an ephemeral thing, and feed content from outside the * owner is untrusted content. Sourcing a durable fact about their life from one * would be wrong twice over. * * Implementations must be idempotent for a given `(occasionId, occurrence)`. * This service also remembers the external id it was handed, so the ordinary * path does not call the implementation twice for the same occurrence at all. */ export interface OccasionCalendarMirror { mirror(input: { readonly occasion: Occasion; readonly occurrence: IsoDate; /** The id previously returned for this occurrence, when there is one. */ readonly existingExternalId?: string | undefined; }): Promise; } export interface OccasionsServiceDeps { readonly profile: OccasionProfileSource; readonly writer: OccasionProfileWriter; readonly state: OccasionStateStore; readonly config: OccasionsConfigAccess; readonly deliverer?: OccasionNudgeDeliverer | undefined; readonly calendar?: OccasionCalendarMirror | undefined; readonly now?: (() => number) | undefined; } /** One occasion with everything a surface needs to render it. */ export interface OccasionView { readonly occasion: Occasion; readonly nextOccurrence: IsoDate | null; /** Whole days away. Present for a surface that IS the owner, never in a nudge. */ readonly daysUntil: number | null; readonly leadDays: number; readonly inLeadWindow: boolean; readonly answer: OccasionAnswer | null; readonly mirrored: boolean; } /** What `occasions.list` answers. */ export interface OccasionListResult { readonly today: IsoDate; readonly timezone: string; readonly occasions: readonly OccasionView[]; readonly unparsed: readonly UnparsedOccasionLine[]; readonly conflicts: readonly OccasionConflict[]; } /** What `occasions.plans.list` answers. */ export interface PlanListResult { readonly today: IsoDate; readonly plans: readonly Plan[]; readonly unparsed: readonly UnparsedPlanLine[]; /** The plan that has the owner away today, if there is one. */ readonly awayNow: Plan | null; } /** What one sweep did. */ export interface SweepOutcome { readonly ranAt: number; readonly today: IsoDate; readonly hold: SweepHold; readonly nudge: OccasionNudge | null; readonly conflictMessages: readonly string[]; readonly resumedInterviews: readonly string[]; /** True when at least one destination accepted the nudge. */ readonly delivered: boolean; /** Every destination it was addressed to, as configured. Empty ⇒ pull-only. */ readonly deliveryChannel: string; /** The id from the first destination that accepted it. */ readonly deliveryId: string | null; /** One entry per destination attempted, in configured order. */ readonly deliveries: readonly NudgeDelivery[]; readonly mirrored: number; readonly housekeeping: OccasionSweepReport | null; } /** What the interview verbs answer. */ export interface InterviewProgress { readonly interviewId: string; readonly occasionId: string; readonly occurrence: IsoDate; readonly steps: readonly InterviewStep[]; readonly nextStep: InterviewStep | null; readonly complete: boolean; readonly landedOn: string | null; } /** * Everything outstanding, for a surface that pulls rather than receives. * * The occasion half is composed by `pending.ts`; the interviews are added here * because an interview's progress is this service's shape. */ export interface PendingResult extends ComposedPending { readonly interviews: readonly InterviewProgress[]; } export declare class OccasionsService { private readonly deps; constructor(deps: OccasionsServiceDeps); private now; private timezone; private today; /** The effective policy, read live on every call. */ policy(): OccasionsPolicy; /** * Every declared occasion, with what the machine knows about each. * * This is the answer a surface renders. It DOES carry the date and the day * count, and that is not a contradiction of the never-the-date rule: this is * the owner asking their own system what it holds, over an authenticated verb, * which is exactly the explicit ask that unlocks a closed-tier read. The rule * is about what an unprompted message pushes onto a channel. */ list(): Promise; /** Every declared plan, and whether one has the owner away right now. */ listPlans(): PlanListResult; /** What the machine-owned store is holding, and what the last sweep removed. */ disclose(): Promise; /** What the owner landed on before, for one occasion. */ giftHistory(occasionId: string): Promise; /** What would be written, and the one line to put to the owner. Writes nothing. */ proposeOccasion(input: ProposeOccasionInput): OccasionProposal; /** Write the confirmed occasion. Refuses without a kind rather than guessing. */ confirmOccasion(input: ConfirmOccasionInput): Promise; /** The same two-step capture, for a plan. */ proposePlan(input: ProposePlanInput): OccasionProposal; confirmPlan(input: ConfirmPlanInput): Promise; /** Remove an occasion and everything the machine remembered about it. */ removeOccasion(input: { readonly occasionId: string; readonly confirmed: boolean; readonly authority: AuthoritySurface; }): Promise; /** * Record yes, no or later for one occurrence. * * - **no**, silent for the rest of this cycle. The record expires with the * occurrence, so next year asks fresh carrying no memory of the refusal. * - **later**, not a decline. It comes back roughly halfway to the date. * - **yes**, opens the interview, and the answer is what stops the nudging. * * A one-off carries no expiry: "handled" is permanent for something that * happens once. */ answer(input: { readonly occasionId: string; readonly answer: OccasionAnswer; readonly occurrence?: string | undefined; }): Promise<{ readonly ok: boolean; readonly reason: string | null; readonly interview: InterviewProgress | null; }>; private startInterview; /** The interview for one occasion, resumed at the question the owner did not answer. */ interview(interviewId: string): Promise; /** Record one answer and hand back the next question, if there is one. */ answerInterview(input: { readonly interviewId: string; readonly stepId: string; readonly text: string; }): Promise; /** Mute the push for an occurrence the owner is actively choosing a gift for. */ private autoAcknowledgeFromGiftFlow; /** * Close the interview with what the owner landed on, and write the gift history. * * Recording the OUTCOME is the point: "the owner said yes in 2026" cannot stop year * three steering where year one did, and a history that only holds answers is * a history of questions. */ recordGiftOutcome(input: { readonly interviewId: string; readonly landedOn: string; }): Promise; /** * One pass: reap, decide, mirror, deliver, remember. * * Housekeeping runs FIRST and unconditionally, before the enabled check and * before quiet hours, because a store that only reaps when the feature is * allowed to speak is a store that never reaps on a machine where the owner turned * nudging off. */ sweep(): Promise; /** Unfinished interviews, for the resume case. */ private interviewsInFlight; /** * Write occasions out to the calendar, once each. * * A previously recorded external id is passed back to the implementation so * the mirror is idempotent even where the calendar itself has no natural key, * and an occurrence already mirrored is not offered again at all. */ private runMirror; /** * Everything outstanding, without delivering anything. * * This is how a surface that is not a push destination receives a nudge: it * pulls what is open at the top of a turn rather than being pushed at. A * stored date is the prior scheduling that permits raising something * unprompted, which is what keeps this consistent with the agent being * conversation-first. * * When the agent IS a configured push destination, an item a push has already * landed there is left out. The push and the pull are two ways of getting the * same thing to the same conversation, and doing both would have the agent * raise one birthday twice. The condition is the LANDED push rather than the * configuration: an item no push has ever landed on the agent, because the * sender is not registered, or because the send failed, carries no stamp and * still comes back here, so the guard cannot turn into a way of dropping a * nudge. */ pending(): Promise; /** * Record that the owner has one occurrence in hand. See acknowledge.ts. * * Deliberately NOT a fourth value routed through {@link answer}: a `yes` or a * `no` resolves the open item and removes it, and this one must leave it * standing. Same store, different promise. */ acknowledge(input: { readonly occasionId: string; readonly source: OccasionAckSource; readonly occurrence?: string | undefined; }): Promise<{ readonly ok: boolean; readonly reason: string | null; readonly reply: string; }>; /** Mark a conflict as dealt with, so it stops being re-raised. */ resolveConflict(occasionId: string): Promise; } //# sourceMappingURL=service.d.ts.map