import { profileFieldById } from './fields.js'; import type { AppendProfileProseInput, ForgetProfileInput, OwnerProfileStoreOptions, ProfileDocumentView, ProfileProvenanceReport, SetProfileFieldInput, UndoProfileInput } from './store-types.js'; import type { ProfileFieldValue, ProfileLine, ProfileLoadState, ProfileSection, ProfileWriteResult } from './types.js'; /** Default poll interval where `fs.watch` is unavailable. Off the read path. */ export declare const DEFAULT_PROFILE_RELOAD_THROTTLE_MS = 2000; export declare class OwnerProfileStore { private readonly filePath; private readonly enabled; private readonly throttleMs; private readonly now; private readonly options; /** The whole model. Replaced wholesale, never mutated in place. */ private projection; private state; private watcher; private pollTimer; private debounceTimer; private reloading; /** The stat of the file this store itself last wrote, so its own write is not a change. */ private ownWrite; /** * The stat of the file content the current projection reflects. * * A write compares against this to notice that the owner edited the file * underneath it. Distinct from `ownWrite`, which answers the watcher's * different question ("was that event mine?"). */ private lastSeen; constructor(options?: OwnerProfileStoreOptions); get path(): string; /** * Read, project, swap. * * `profile.enabled = false` means the file is not opened at all and every verb * answers "profile is disabled", a stated state, not an empty profile. */ load(): Promise; /** * The same load, synchronously, for the ONE caller that cannot await. * * A daemon composition root is synchronous. Loading asynchronously there left * a window in which every verb answered "your profile has not been loaded * yet" (not a state §4.4 sanctions) and, worse because nothing logged it, the * config fallback answered UNSET and the open-tier block rendered empty. See * store-load.ts for why a readiness promise could not have closed that. */ loadSync(): ProfileLoadState; /** Turn one read into the load state, or report the profile turned off. */ private adoptRead; /** True when the file on disk is still the content the projection reflects. */ private matchesLastSeen; /** Build the status from a finished projection, then swap both in one step. */ private adopt; /** * Report unavailable and DROP the previous projection. * * Keeping it would mean a broken file silently kept answering with values that * no longer correspond to anything on disk. */ private markUnavailable; /** * Pick up a hand edit without a restart. * * The watch is on the CONTAINING DIRECTORY, filtered by filename, not on the * file. The atomic write in `persistProfileText` replaces the file's inode, and * an `fs.watch` handle bound to a file is bound to that inode: after the first * write it would be watching an unlinked inode and would never fire again. The * symptom is the kind that survives review, hand edits work perfectly until * the first autonomous write, then are ignored forever with no way to tell why. * * Where `fs.watch` throws (some filesystems, some containers) a throttled * `stat` poll takes over. Neither path touches a read. */ watch(): void; /** Stop watching. Safe to call when nothing is running. */ unwatch(): void; private closeWatcher; private startPolling; /** Collapse a burst of events into one reload, skipping this store's own write. */ private scheduleReload; private reloadIfChanged; /** One mechanical field, or `undefined` when unset, unavailable or disabled. */ get(fieldId: string): ProfileFieldValue | undefined; /** * One section by heading, OPEN TIER ONLY. * * A closed-tier section returns `undefined`, `People` included. This is the * STRUCTURE behind {@link person}'s guarantee rather than a comment asserting * it. `section('People')` was an enumerate-all-people call sitting next to the * by-name lookup that exists precisely so no such call is available, which * re-opens the failure §10 rules out: "the model judged it relevant" is not a * boundary, because the model's judgement is the thing an injection attacks. * * Everything closed is still reachable, by a route that is either named or * addressed to the owner: * - `People` → {@link person}, by a name they used this turn * - a mechanical field → {@link get}, by field id * - the whole document → {@link read}, the owner-disclosure verb * * A heading they invented is treated as closed. Their own sections can hold * anything, and defaulting them open would mean a section named by nobody in * particular became bulk-readable from a composition path. */ section(name: string): ProfileSection | undefined; /** * Unfiltered section lookup, for this class's own use only. * * `person()` goes through this rather than through the public `section()`, so * the tier filter is not something a caller can sidestep by reaching for * whichever method happens to skip it, and so tightening the public method * cannot silently break the private one. */ private sectionByHeading; /** * The lines about one person, BY NAME. * * There is deliberately no enumerate-all-people counterpart, and `section()` * refusing the closed tier is what makes that true rather than merely stated. * A `People` line may reach outbound content only when the owner named that * person in this turn's instruction, and the structural guarantee behind that * rule is that the only lookup available takes a name. * * An empty or whitespace-only name returns nothing rather than everything, * "the owner named nobody" must not degrade into "give me all of them", * which is the shape this kind of guard usually fails in. * * Two things make that hold rather than nearly hold: * * - The name must contain a LETTER OR DIGIT. `person('-')` used to return * every line in the section: `ProfileLine.text` keeps the `- ` list marker, * and the word-boundary alternative `(^|[^\p{L}\p{N}])` matches at index 0 * of every bullet, so one character of punctuation was a complete * enumerate-all call. Rejecting empty-after-trim is not the same test. * - Matching runs against the line with its list marker STRIPPED, so the * marker cannot participate in a boundary match at all. Belt and braces: * either fix alone closes the measured case, and the pair closes the shape. */ person(name: string): readonly ProfileLine[]; /** * The declared occasions, as raw prose lines. * * ## Why this is a named method rather than `section('Important dates')` * * `section()` refuses the closed tier, and this section is closed, it holds * family birth dates, which are the single most obvious thing that must never * be bulk-injected into a prompt or a message channel. The daemon still has to * read the whole section, because the approach sweep's entire job is "which of * these is coming up". * * So it gets a route that is NAMED and narrow, the same shape `person()` has, * rather than a widened `section()`. Two properties make that safe rather than * merely stated: * * - The only consumer is the sweep, and the sweep's OUTPUT, the nudge, * carries the occasion and the person and never the date. The date reaches * a message channel through no path at all. * - There is no generic "give me a closed section" call. Widening this to one * would re-open the enumerate-all hole `section()` exists to close, by a * different name. */ importantDates(): readonly ProfileLine[]; /** * The declared plans, as raw prose lines. Same reasoning as * {@link importantDates}: closed tier, one named consumer, no generic * counterpart. */ plans(): readonly ProfileLine[]; /** Provenance for one field, plus every superseded predecessor. */ provenance(fieldId: string): ProfileProvenanceReport; /** * The whole document, by section, the ONLY method that returns the full * `People` section. * * The asymmetry with {@link section} is deliberate, not an inconsistency. * `read()` answers "what do you know about me?": it is the owner asking * about themselves, and an answer that silently omitted the section holding * facts about the people around them would be a dishonest disclosure, the one place * where withholding is the wrong behaviour. `section()` serves a consumer * assembling something, where bulk access to that same content is exactly the * hole §10 closes. * * The rule that keeps both true: `read()` is reachable only from the * `profile.read` control-plane verb, and never from a composition path. Any * other caller reaching for it is the enumerate-all hole by another route, * check that before wiring it into anything new. */ read(): ProfileDocumentView; private viewOf; /** Load state, path, section names, counts and invalid fields. Never a value. */ status(): ProfileLoadState; /** The canonical section names, for a caller building a settings surface. */ static sections(): readonly string[]; /** Write or supersede a mechanical field. */ set(input: SetProfileFieldInput): Promise; /** Add a prose bullet to a section. */ append(input: AppendProfileProseInput): Promise; /** Delete a line and, for a field, every history comment it left behind. */ forget(input: ForgetProfileInput): Promise; /** Promote the most recent superseded value back to an active line. */ undo(input: UndoProfileInput): Promise; private provenanceFor; /** The projection a write may edit, or the reason there is not one. */ private writableProjection; /** * Compute an edit against the CURRENT file, persist it, then re-project. * * ## Why this takes an operation rather than a finished edit * * §3 says the daemon is the single writer, and that is what makes a * rename-based atomic write sufficient with no lock. It is not true. The * OWNER is a second writer by design, §4.5 exists precisely so they can open * the file and change it, and a write computed from a projection loaded * minutes ago joins the whole document, so every line they changed in between * is overwritten by a stale copy. It is silent: they get a success receipt and * their edits are simply gone, which is the worst failure this design can have * in a file whose entire premise is that their edits win. * * ## The rule * * Detect, reload, REPLAY, do not clobber, and do not merely refuse. The * file's stat is compared against what this store last saw; if it moved, the * document is re-read, re-projected, and the operation is re-run against the * fresh projection so the owner's edit and this write both survive. The stat is * re-checked immediately before the rename, and a write that keeps losing the * race is refused rather than forced. * * This is not a lock and does not claim to be. It closes the minutes-wide * window (a stale projection) and narrows the remaining one to the few * milliseconds between the final stat and the rename. Two DAEMONS writing * concurrently would still need a real lock; the owner editing their own file * while the daemon runs is the case this design actually has, and it is now * handled rather than assumed away. */ private commit; } /** Re-exported so a caller holding a field id can name it without a second import. */ export { profileFieldById }; /** * The store's own input and view shapes, which live in `store-types.ts` because * this file hit the 800-line cap. Re-exported here so `store.js` remains the one * import path for them and nothing downstream had to move. */ export type { AppendProfileProseInput, ForgetProfileInput, OwnerProfileStoreOptions, ProfileDocumentView, ProfileFieldView, ProfileProvenanceReport, ProfileSectionView, SetProfileFieldInput, UndoProfileInput, } from './store-types.js'; //# sourceMappingURL=store.d.ts.map