ctxfile 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,942 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+
4
+ interface ConnectorStatus {
5
+ name: string;
6
+ status: "ok" | "skipped" | "error";
7
+ error?: string;
8
+ durationMs: number;
9
+ }
10
+ interface KeyFile {
11
+ path: string;
12
+ tokens: number;
13
+ truncated: boolean;
14
+ redactions: number;
15
+ content: string;
16
+ }
17
+ interface GitCommit {
18
+ hash: string;
19
+ date: string;
20
+ message: string;
21
+ author: string;
22
+ }
23
+ interface GitState {
24
+ branch: string;
25
+ staged: string[];
26
+ modified: string[];
27
+ untracked: string[];
28
+ ahead: number;
29
+ behind: number;
30
+ commits: GitCommit[];
31
+ diffSummary: string;
32
+ }
33
+ interface NotionPage {
34
+ id: string;
35
+ title: string;
36
+ lastEditedTime: string;
37
+ content: string;
38
+ }
39
+ interface SessionDigest {
40
+ /** Originating tool, e.g. "claude-code", "cursor", "codex", "opencode",
41
+ "gemini-cli", "aider", "openclaw". Open set: new session connectors
42
+ add sources without a core type change. */
43
+ source: string;
44
+ sessionId: string;
45
+ startedAt: string | null;
46
+ lastActiveAt: string | null;
47
+ turnCount: number;
48
+ /** Redacted, token-capped rolling summary of the conversation's text turns. */
49
+ digest: string;
50
+ }
51
+ interface ContextMeta {
52
+ name: "ctxfile";
53
+ version: string;
54
+ generatedAt: string;
55
+ root: string;
56
+ tokenBudget: number;
57
+ tokensUsed: number;
58
+ connectors: ConnectorStatus[];
59
+ }
60
+ interface ContextObject {
61
+ meta: ContextMeta;
62
+ plan: string | null;
63
+ keyFiles: KeyFile[];
64
+ gitState: GitState | null;
65
+ notionPages: NotionPage[];
66
+ /** Recent agent-session digests (populated by Pro session connectors). */
67
+ sessions?: SessionDigest[];
68
+ sessionSummary: string | null;
69
+ }
70
+ type ContextScope = "full" | "plan" | "files" | "git";
71
+ /** Progress events emitted while a snapshot builds (consumed by the local UI's SSE stream). */
72
+ type BuildEvent = {
73
+ type: "connector:start";
74
+ name: string;
75
+ } | {
76
+ type: "connector:done";
77
+ connector: ConnectorStatus;
78
+ } | {
79
+ type: "tokens";
80
+ tokensUsed: number;
81
+ tokenBudget: number;
82
+ } | {
83
+ type: "done";
84
+ generatedAt: string;
85
+ };
86
+
87
+ /**
88
+ * `ctxfile export`: the ContextObject as a static, self-describing artifact
89
+ * (".ctxfile convention") that cloud agents read from the repo or a CI
90
+ * artifact. The envelope keys are snake_case and frozen by the public schema;
91
+ * the embedded context keeps the camelCase shape `get_context` already serves.
92
+ */
93
+ declare const EXPORT_SCHEMA_VERSION = "1";
94
+ declare const EXPORT_PROFILES: readonly ["repo-safe", "full", "custom"];
95
+ type ExportProfile = (typeof EXPORT_PROFILES)[number];
96
+ declare const EXPORT_SECTIONS: readonly ["plan", "gitState", "keyFiles", "keyFileContent", "notionPages", "sessions", "sessionSummary"];
97
+ type ExportSection = (typeof EXPORT_SECTIONS)[number];
98
+ interface ExportedKeyFile {
99
+ path: string;
100
+ tokens: number;
101
+ truncated: boolean;
102
+ redactions: number;
103
+ /** Present only when the "keyFileContent" section is included. */
104
+ content?: string;
105
+ }
106
+ interface ExportedContext {
107
+ meta: ContextMeta;
108
+ plan: string | null;
109
+ keyFiles: ExportedKeyFile[];
110
+ gitState: GitState | null;
111
+ notionPages: NotionPage[];
112
+ sessions?: SessionDigest[];
113
+ sessionSummary: string | null;
114
+ }
115
+ interface ExportEnvelope {
116
+ ctxfile_schema: typeof EXPORT_SCHEMA_VERSION;
117
+ ctxfile_version: string;
118
+ profile: ExportProfile;
119
+ /** When this artifact was written (drift detection, together with git_sha). */
120
+ generated_at: string;
121
+ /** When the underlying snapshot was built. */
122
+ snapshot_generated_at: string;
123
+ git_sha: string | null;
124
+ /** Basename only; the absolute local path never leaves the machine. */
125
+ root_name: string;
126
+ /** Sections actually present, so a found file explains itself. */
127
+ sections: ExportSection[];
128
+ context: ExportedContext;
129
+ }
130
+ interface BuildExportOptions {
131
+ profile: ExportProfile;
132
+ /** Section allowlist for the "custom" profile (falls back to repo-safe). */
133
+ customSections?: ExportSection[] | null;
134
+ /** Clock override for tests. */
135
+ now?: () => Date;
136
+ }
137
+ declare function buildExportEnvelope(ctx: ContextObject, options: BuildExportOptions): ExportEnvelope;
138
+ /** Human/agent-readable render of the same artifact (context.md). */
139
+ declare function renderExportMarkdown(envelope: ExportEnvelope): string;
140
+
141
+ /** Scopes for the HTTP door: what a bearer token may do on a connection. */
142
+ declare const SERVE_SCOPES: readonly ["read:context", "write:sessions"];
143
+ type ServeScope = (typeof SERVE_SCOPES)[number];
144
+ interface ConsultProviderSpec {
145
+ type: "anthropic" | "openai-compatible" | "ollama";
146
+ model?: string;
147
+ baseUrl?: string;
148
+ /** Name of the env var holding the API key — never the key itself. */
149
+ apiKeyEnv?: string;
150
+ }
151
+ interface ServeTokenSpec {
152
+ name: string;
153
+ /** Name of the env var holding the bearer token — never the token itself. */
154
+ tokenEnv: string;
155
+ scopes: ServeScope[];
156
+ }
157
+ interface ResolvedConfig {
158
+ root: string;
159
+ tokenBudget: number;
160
+ maxFileTokens: number;
161
+ cacheDir: string;
162
+ cacheMaxAgeMs: number;
163
+ include: string[];
164
+ exclude: string[];
165
+ notion: {
166
+ token: string | null;
167
+ pageIds: string[];
168
+ };
169
+ ollama: {
170
+ baseUrl: string;
171
+ model: string | null;
172
+ summarize: boolean;
173
+ };
174
+ voice: {
175
+ whisperPath: string | null;
176
+ modelPath: string | null;
177
+ audioDir: string | null;
178
+ };
179
+ consult: {
180
+ providers: ConsultProviderSpec[];
181
+ };
182
+ telemetry: {
183
+ enabled: boolean;
184
+ endpoint: string;
185
+ };
186
+ export: {
187
+ profile: ExportProfile;
188
+ include: ExportSection[] | null;
189
+ };
190
+ behavior: {
191
+ debounceMinutes: number;
192
+ };
193
+ /** The HTTP door (`ctxfile serve`): loopback default, bearer tokens by env var. */
194
+ serve: {
195
+ port: number;
196
+ host: string;
197
+ tokens: ServeTokenSpec[];
198
+ };
199
+ }
200
+ interface LoadConfigOptions {
201
+ root?: string;
202
+ configPath?: string;
203
+ env?: NodeJS.ProcessEnv;
204
+ }
205
+ declare function loadConfig(opts?: LoadConfigOptions): ResolvedConfig;
206
+
207
+ declare function estimateTokens(text: string): number;
208
+ declare function truncateToTokens(text: string, maxTokens: number): {
209
+ text: string;
210
+ truncated: boolean;
211
+ };
212
+ declare class TokenBudget {
213
+ private readonly total;
214
+ private consumed;
215
+ constructor(total: number);
216
+ used(): number;
217
+ remaining(): number;
218
+ take(tokens: number): boolean;
219
+ }
220
+
221
+ interface SnapshotInput {
222
+ config: ResolvedConfig;
223
+ budget: TokenBudget;
224
+ }
225
+ interface Connector {
226
+ name: string;
227
+ isEnabled(config: ResolvedConfig): boolean;
228
+ snapshot(input: SnapshotInput): Promise<Partial<ContextObject>>;
229
+ }
230
+ interface Summarizer {
231
+ name: string;
232
+ isEnabled(config: ResolvedConfig): boolean;
233
+ summarize(ctx: ContextObject, config: ResolvedConfig): Promise<string | null>;
234
+ }
235
+
236
+ declare const fileConnector: Connector;
237
+
238
+ declare const gitConnector: Connector;
239
+
240
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
241
+ interface NotionConnectorOptions {
242
+ fetchImpl?: FetchLike;
243
+ /** Minimum ms between requests. Notion allows ~3 req/s per connection. */
244
+ minIntervalMs?: number;
245
+ }
246
+ declare function createNotionConnector(options?: NotionConnectorOptions): Connector;
247
+
248
+ interface OllamaSummarizerOptions {
249
+ fetchImpl?: FetchLike;
250
+ healthTimeoutMs?: number;
251
+ }
252
+ declare function createOllamaSummarizer(options?: OllamaSummarizerOptions): Summarizer;
253
+
254
+ declare function buildContext(config: ResolvedConfig, connectors: Connector[], summarizer: Summarizer | null, scope?: ContextScope, onEvent?: (event: BuildEvent) => void): Promise<ContextObject>;
255
+ declare function filterScope(ctx: ContextObject, scope: ContextScope): ContextObject;
256
+
257
+ declare class SnapshotCache {
258
+ private readonly db;
259
+ constructor(dbPath: string);
260
+ save(root: string, ctx: ContextObject, configFingerprint?: string, agentId?: string): void;
261
+ latest(root: string, maxAgeMs: number, configFingerprint?: string, agentId?: string): ContextObject | null;
262
+ /** Newest-first snapshot summaries for the UI freshness timeline. Corrupt rows are skipped. */
263
+ recent(root: string, limit?: number, agentId?: string): {
264
+ createdAt: number;
265
+ tokensUsed: number;
266
+ }[];
267
+ close(): void;
268
+ }
269
+
270
+ /**
271
+ * Agent-assisted session ingest: the inverse of the session parsers. Instead
272
+ * of ctxfile reverse-engineering a harness's storage, the client surface's
273
+ * own agent formats its session per this published schema and calls
274
+ * `ingest_context` (bulk/agent door) or `save_session` (conversational door).
275
+ * The prompt is the adapter. Parsers stay the invisible default (Pro); ingest
276
+ * is the universal floor for every harness, including ones not written yet.
277
+ *
278
+ * Schema v2 adds threads (durable identities that outlive any one provider's
279
+ * chat history), session lineage via `continues_from`, and the handoff
280
+ * package: when `handoff: true`, validation enforces everything a cold
281
+ * takeover needs, so any agent on any harness produces the same artifact.
282
+ *
283
+ * This is a write path fed by LLM output, so provenance is non-negotiable:
284
+ * every record is stamped `reported_by: "agent"` plus the door it came
285
+ * through, labeled as agent-reported wherever it surfaces, reviewable and
286
+ * deletable via `ctxfile ingest`.
287
+ */
288
+ declare const INGEST_SCHEMA_VERSION = "2";
289
+ declare const ingestSourceSchema: z.ZodObject<{
290
+ harness: z.ZodString;
291
+ harness_version: z.ZodOptional<z.ZodString>;
292
+ }, z.core.$strict>;
293
+ declare const ingestSessionSchema: z.ZodObject<{
294
+ session_id: z.ZodOptional<z.ZodString>;
295
+ started_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
296
+ ended_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
297
+ summary: z.ZodString;
298
+ key_decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
299
+ files_touched: z.ZodDefault<z.ZodArray<z.ZodString>>;
300
+ open_items: z.ZodDefault<z.ZodArray<z.ZodString>>;
301
+ thread: z.ZodOptional<z.ZodString>;
302
+ continues_from: z.ZodOptional<z.ZodString>;
303
+ handoff: z.ZodOptional<z.ZodBoolean>;
304
+ state: z.ZodOptional<z.ZodString>;
305
+ gotchas: z.ZodOptional<z.ZodArray<z.ZodString>>;
306
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodObject<{
307
+ ref: z.ZodString;
308
+ role: z.ZodString;
309
+ }, z.core.$strict>>>;
310
+ suggested_first_prompt: z.ZodOptional<z.ZodString>;
311
+ trigger: z.ZodOptional<z.ZodEnum<{
312
+ auto: "auto";
313
+ manual: "manual";
314
+ }>>;
315
+ }, z.core.$strict>;
316
+ declare const ingestInputSchema: z.ZodObject<{
317
+ ctxfile_ingest_schema: z.ZodEnum<{
318
+ 1: "1";
319
+ 2: "2";
320
+ }>;
321
+ source: z.ZodObject<{
322
+ harness: z.ZodString;
323
+ harness_version: z.ZodOptional<z.ZodString>;
324
+ }, z.core.$strict>;
325
+ session: z.ZodObject<{
326
+ session_id: z.ZodOptional<z.ZodString>;
327
+ started_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
328
+ ended_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
329
+ summary: z.ZodString;
330
+ key_decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
331
+ files_touched: z.ZodDefault<z.ZodArray<z.ZodString>>;
332
+ open_items: z.ZodDefault<z.ZodArray<z.ZodString>>;
333
+ thread: z.ZodOptional<z.ZodString>;
334
+ continues_from: z.ZodOptional<z.ZodString>;
335
+ handoff: z.ZodOptional<z.ZodBoolean>;
336
+ state: z.ZodOptional<z.ZodString>;
337
+ gotchas: z.ZodOptional<z.ZodArray<z.ZodString>>;
338
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodObject<{
339
+ ref: z.ZodString;
340
+ role: z.ZodString;
341
+ }, z.core.$strict>>>;
342
+ suggested_first_prompt: z.ZodOptional<z.ZodString>;
343
+ trigger: z.ZodOptional<z.ZodEnum<{
344
+ auto: "auto";
345
+ manual: "manual";
346
+ }>>;
347
+ }, z.core.$strict>;
348
+ }, z.core.$strict>;
349
+ type IngestInput = z.infer<typeof ingestInputSchema>;
350
+ /** save_session takes the session fields directly (no envelope); the harness
351
+ is inferred from the connected client unless declared explicitly. */
352
+ declare const saveSessionSchema: z.ZodObject<{
353
+ session_id: z.ZodOptional<z.ZodString>;
354
+ started_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
355
+ ended_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
356
+ summary: z.ZodString;
357
+ key_decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
358
+ files_touched: z.ZodDefault<z.ZodArray<z.ZodString>>;
359
+ open_items: z.ZodDefault<z.ZodArray<z.ZodString>>;
360
+ thread: z.ZodOptional<z.ZodString>;
361
+ continues_from: z.ZodOptional<z.ZodString>;
362
+ handoff: z.ZodOptional<z.ZodBoolean>;
363
+ state: z.ZodOptional<z.ZodString>;
364
+ gotchas: z.ZodOptional<z.ZodArray<z.ZodString>>;
365
+ artifacts: z.ZodOptional<z.ZodArray<z.ZodObject<{
366
+ ref: z.ZodString;
367
+ role: z.ZodString;
368
+ }, z.core.$strict>>>;
369
+ suggested_first_prompt: z.ZodOptional<z.ZodString>;
370
+ trigger: z.ZodOptional<z.ZodEnum<{
371
+ auto: "auto";
372
+ manual: "manual";
373
+ }>>;
374
+ harness: z.ZodOptional<z.ZodString>;
375
+ }, z.core.$strict>;
376
+ type SaveSessionInput = z.infer<typeof saveSessionSchema>;
377
+ /** Which tool a record arrived through; part of its provenance. */
378
+ type IngestDoor = "ingest_context" | "save_session";
379
+ interface IngestArtifact {
380
+ ref: string;
381
+ role: string;
382
+ }
383
+ /** One stored, provenance-stamped ingest record. */
384
+ interface IngestedSession {
385
+ id: number;
386
+ root: string;
387
+ harness: string;
388
+ harnessVersion: string | null;
389
+ sessionId: string;
390
+ reportedBy: "agent";
391
+ door: IngestDoor;
392
+ trigger: "auto" | "manual";
393
+ startedAt: string | null;
394
+ endedAt: string | null;
395
+ summary: string;
396
+ keyDecisions: string[];
397
+ filesTouched: string[];
398
+ openItems: string[];
399
+ threadId: number | null;
400
+ threadTitle: string | null;
401
+ continuesFrom: string | null;
402
+ handoff: boolean;
403
+ state: string | null;
404
+ gotchas: string[];
405
+ artifacts: IngestArtifact[];
406
+ suggestedFirstPrompt: string | null;
407
+ ingestedAt: string;
408
+ updatedAt: string;
409
+ revision: number;
410
+ }
411
+ /** Turns a validation failure into the actionable message agents self-correct from. */
412
+ declare function formatIngestErrors(error: z.ZodError, toolName?: string): string;
413
+ /** Stable identity when the harness has no native session id. */
414
+ declare function ingestSessionId(input: IngestInput): string;
415
+ /** Renders an ingest record as a SessionDigest so it rides the normal
416
+ sessions surface, loudly labeled as agent-reported. */
417
+ declare function ingestToSessionDigest(record: IngestedSession, maxTokens?: number): SessionDigest;
418
+ /** Parser wins: an ingest record whose sessionId matches a parser-provided
419
+ session is linked (dropped here), never duplicated. */
420
+ declare function mergeIngestedSessions(parserSessions: SessionDigest[] | undefined, ingested: IngestedSession[]): SessionDigest[] | undefined;
421
+ interface ThreadSummary {
422
+ id: number;
423
+ title: string;
424
+ status: string;
425
+ tags: string[];
426
+ /** Private threads are excluded from behavior-layer auto-capture (§4.1). */
427
+ private: boolean;
428
+ createdAt: string;
429
+ lastActiveAt: string;
430
+ sessionCount: number;
431
+ lastHarness: string | null;
432
+ }
433
+ type ThreadResolution = {
434
+ kind: "resolved";
435
+ thread: ThreadSummary;
436
+ assumed: boolean;
437
+ } | {
438
+ kind: "ambiguous";
439
+ candidates: ThreadSummary[];
440
+ } | {
441
+ kind: "none";
442
+ };
443
+ /** Deterministic fuzzy score in [0, 1]: exact 1, containment 0.85, tag hit
444
+ 0.8, else token overlap scaled into (0.3, 0.8]. */
445
+ declare function scoreThreadMatch(query: string, thread: Pick<ThreadSummary, "title" | "tags">): number;
446
+ /** The "you know what I mean" rules: named thread fuzzy-matches; no name
447
+ defaults to the most recently active (and the caller says so); a genuine
448
+ tie returns the shortlist to ask with. */
449
+ declare function resolveThread(query: string | undefined, threads: ThreadSummary[]): ThreadResolution;
450
+ /** Renders continue_thread's merged history: chronological order, per-entry
451
+ provenance labels, newest-detailed/oldest-summarized token budgeting.
452
+ Shared by the local server and the relay's Standard-mode vault serving. */
453
+ declare function renderThreadResume(thread: ThreadSummary, sessions: IngestedSession[], assumed: boolean): string;
454
+ /** Maps an MCP client's advertised name to a harness id, so save_session
455
+ callers never have to know our enum. Unknown clients become custom:<name>. */
456
+ declare function inferHarnessFromClientName(clientName: string | undefined): string;
457
+
458
+ /**
459
+ * The Sync mailbox protocol, client side (M2). Push/pull of encrypted blobs
460
+ * with last-write-wins per blob and tombstones; no CRDTs, because a vault is
461
+ * single-user. The relay implements RelayStore (M3: Fly.io/R2; tests: an
462
+ * in-memory stub) and only ever sees opaque ids, version numbers, and
463
+ * ciphertext. Both sides converge because versions are the source records'
464
+ * own updated-at clocks and apply() is idempotent.
465
+ */
466
+ /** What the relay sees per blob: opaque id, version, deletion flag. */
467
+ interface SyncBlobMeta {
468
+ id: string;
469
+ version: number;
470
+ deleted: boolean;
471
+ }
472
+ /** The relay contract. M3 implements this over HTTP + R2; the same shape
473
+ self-hosts as the Team hub's storage. */
474
+ interface RelayStore {
475
+ list(): Promise<SyncBlobMeta[]>;
476
+ get(id: string): Promise<{
477
+ meta: SyncBlobMeta;
478
+ data: Uint8Array;
479
+ } | null>;
480
+ put(id: string, meta: SyncBlobMeta, data: Uint8Array): Promise<void>;
481
+ }
482
+ /** One syncable record, plaintext side. The payload carries its own natural
483
+ identity, so tombstones and cross-device applies never depend on ids the
484
+ relay can read. */
485
+ interface SyncEntry {
486
+ naturalId: string;
487
+ /** LWW clock: the record's own updated-at, in ms. */
488
+ version: number;
489
+ deleted: boolean;
490
+ payload: Uint8Array;
491
+ }
492
+ interface LocalBlobSource {
493
+ snapshot(): Promise<SyncEntry[]>;
494
+ /** Applies remote entries with its own LWW check; returns how many changed local state. */
495
+ apply(entries: SyncEntry[]): Promise<number>;
496
+ }
497
+ interface SyncResult {
498
+ pushed: number;
499
+ applied: number;
500
+ }
501
+ declare class SyncClient {
502
+ private readonly local;
503
+ private readonly relay;
504
+ private readonly key;
505
+ constructor(local: LocalBlobSource, relay: RelayStore, key: Uint8Array);
506
+ private opaqueIds;
507
+ /** Uploads every local blob that is newer than (or absent from) the relay. */
508
+ push(): Promise<number>;
509
+ /** Downloads and applies every remote blob newer than local state. */
510
+ pull(): Promise<number>;
511
+ /** Pull first (so local LWW sees the freshest remote), then push. */
512
+ sync(): Promise<SyncResult>;
513
+ }
514
+
515
+ interface IngestResult {
516
+ id: number;
517
+ sessionId: string;
518
+ revision: number;
519
+ action: "created" | "updated";
520
+ threadId: number | null;
521
+ threadTitle: string | null;
522
+ }
523
+ declare class IngestStore {
524
+ private readonly db;
525
+ constructor(dbPath: string);
526
+ /** Idempotent v2 migration: column-presence checks, never version guesses,
527
+ so a v1 database upgrades in place and a v2 one is untouched. */
528
+ private migrate;
529
+ /** Case-insensitive find-or-create; titles are redacted like all content.
530
+ Re-using a tombstoned (deleted) title resurrects that row — clearing
531
+ `deleted` — rather than bumping its clock while it stays deleted (which
532
+ would attach a session to an invisible thread and re-push a tombstone with
533
+ a newer clock). A unique (root, lower(title)) index makes one row per
534
+ title the only possibility, so resurrection is the correct move. */
535
+ ensureThread(root: string, title: string, now?: number): {
536
+ id: number;
537
+ title: string;
538
+ created: boolean;
539
+ };
540
+ /** The thread of a prior session, so `continues_from` carries lineage even
541
+ when the reporting agent never names the thread. */
542
+ private threadOfSession;
543
+ private threadTitle;
544
+ /** Upsert on (root, harness, session_id): re-ingest updates with history. */
545
+ ingest(root: string, input: IngestInput, now?: number, door?: IngestDoor): IngestResult;
546
+ /** Newest-first records for this project. */
547
+ list(root: string, limit?: number): IngestedSession[];
548
+ /** All threads for this project, most recently active first. */
549
+ listThreads(root: string): ThreadSummary[];
550
+ /** A thread's sessions in chronological (oldest-first) order. */
551
+ threadSessions(root: string, threadId: number): IngestedSession[];
552
+ /** The newest auto checkpoint on a thread, for the behavior-layer debounce
553
+ (§4.2: reject unchanged checkpoints inside the window). */
554
+ latestAutoForThread(root: string, threadTitle: string): IngestedSession | null;
555
+ /** Marks a thread private (excluded from auto-capture) or public again. */
556
+ setThreadPrivate(root: string, threadId: number, isPrivate: boolean, now?: number): boolean;
557
+ /** Whether the (case-insensitive) titled thread is private; false when the
558
+ thread does not exist yet. */
559
+ threadIsPrivate(root: string, title: string): boolean;
560
+ /** Deletes one record by numeric id (as shown by `ctxfile ingest list`).
561
+ A tombstone, not a hard delete, so the deletion syncs like any write. */
562
+ remove(root: string, id: number, now?: number): boolean;
563
+ /** Everything syncable for this root, tombstones included. */
564
+ exportSyncEntries(root: string): SyncEntry[];
565
+ /** Applies decrypted sync entries with per-record LWW; returns how many
566
+ changed local state. Content was redacted before it was first stored,
567
+ and blobs are AEAD-authenticated, so imports apply verbatim. */
568
+ importSyncEntries(root: string, entries: SyncEntry[]): number;
569
+ /** This store, adapted to the SyncClient contract for one root. */
570
+ syncSource(root: string): LocalBlobSource;
571
+ private applyThreadPayload;
572
+ private applySessionPayload;
573
+ close(): void;
574
+ }
575
+
576
+ interface SnapshotService {
577
+ getContext(scope?: ContextScope): Promise<ContextObject>;
578
+ rebuild(onEvent?: (event: BuildEvent) => void): Promise<ContextObject>;
579
+ /** Fresh cache hit only (cacheMaxAgeMs), scope-filtered; null means a rebuild is needed. */
580
+ getCached(scope?: ContextScope): ContextObject | null;
581
+ latestCached(): ContextObject | null;
582
+ recentSnapshots(limit?: number): {
583
+ createdAt: number;
584
+ tokensUsed: number;
585
+ }[];
586
+ }
587
+ declare function createSnapshotService(config: ResolvedConfig, deps: {
588
+ cache: SnapshotCache | null;
589
+ connectors: Connector[];
590
+ summarizer: Summarizer | null;
591
+ /** Agent-reported sessions merged into every snapshot (parser wins on id). */
592
+ ingest?: IngestStore | null;
593
+ }): SnapshotService;
594
+
595
+ /**
596
+ * The Behavior Layer (design doc 11): one canonical behavior spec, rendered
597
+ * into per-harness formats. MCP gives an agent tools; this gives it behavior:
598
+ * checkpoint at the right moments, announce every save, detect handoffs. The
599
+ * "software" is prose; this module is the renderer, the installer, and the
600
+ * consent/pause state the server's guardrails read.
601
+ */
602
+ declare const BEHAVIOR_HARNESSES: readonly ["claude-code", "cursor", "agents-md", "codex", "generic"];
603
+ type BehaviorHarness = (typeof BEHAVIOR_HARNESSES)[number];
604
+ /** The canonical spec ships inside the npm package beside dist/. */
605
+ declare function loadCanonicalBehaviors(): string;
606
+ interface RenderedBehavior {
607
+ harness: BehaviorHarness;
608
+ /** Path relative to the render root (or the suggested install location). */
609
+ relativePath: string;
610
+ content: string;
611
+ }
612
+ /** Deterministic renders from the single source; the repo's committed
613
+ render/ folder is regenerated by scripts/render-behaviors.mjs and a test
614
+ fails if it drifts from this function's output. */
615
+ declare function renderBehavior(harness: BehaviorHarness, canonical?: string): RenderedBehavior;
616
+ declare function renderAllBehaviors(canonical?: string): RenderedBehavior[];
617
+ interface DetectedHarness {
618
+ harness: BehaviorHarness;
619
+ reason: string;
620
+ }
621
+ /** Cheap, honest detection: directories the harnesses actually create. */
622
+ declare function detectHarnesses(root: string, home: string): DetectedHarness[];
623
+ interface InstallResult {
624
+ harness: BehaviorHarness;
625
+ target: string;
626
+ action: "created" | "updated" | "printed";
627
+ }
628
+ /** Writes the behavior file where the harness looks for it. Append-style
629
+ targets get the managed block (idempotent re-install); file-style targets
630
+ are whole files we own. */
631
+ declare function installBehavior(harness: BehaviorHarness, root: string, canonical?: string): InstallResult;
632
+ interface UninstallResult {
633
+ harness: BehaviorHarness;
634
+ target: string;
635
+ action: "removed" | "stripped" | "absent";
636
+ }
637
+ /** Reverses installBehavior for one harness. File-style targets we own (the
638
+ claude-code skill dir, the cursor rule) are deleted; the AGENTS.md managed
639
+ block is cut by its markers, leaving any surrounding content intact (and the
640
+ file removed only if our block was all it held). codex/generic were never
641
+ written, so there is nothing to remove. Never touches unrelated content. */
642
+ declare function uninstallBehavior(harness: BehaviorHarness, root: string): UninstallResult;
643
+ interface BehaviorState {
644
+ /** Explicit install-time consent (§4.1). Fail closed: an absent or unreadable
645
+ state file means no consent, so auto-captures are refused until `ctxfile
646
+ init` records it (or `--yes`). */
647
+ autoCapture: boolean;
648
+ paused: boolean;
649
+ consentAt?: string;
650
+ pausedAt?: string;
651
+ }
652
+ declare function readBehaviorState(cacheDir: string): BehaviorState;
653
+ declare function writeBehaviorState(cacheDir: string, state: BehaviorState): void;
654
+ /** Removes the consent/pause record, returning the install to a pre-`init`
655
+ state (fail-closed: auto-capture is then off until consent is recorded
656
+ again). Returns true if a state file was present. */
657
+ declare function clearBehaviorState(cacheDir: string): boolean;
658
+ /** True when automatic captures must be refused right now. */
659
+ declare function autoCaptureBlocked(cacheDir: string): {
660
+ blocked: boolean;
661
+ reason: string | null;
662
+ };
663
+
664
+ declare function inspectLicenseKey(key: string, now?: Date): {
665
+ ok: boolean;
666
+ detail: string;
667
+ };
668
+
669
+ interface ProUiFeatures {
670
+ sessions: boolean;
671
+ memory: boolean;
672
+ consult: boolean;
673
+ voice: boolean;
674
+ }
675
+ interface ProMemoryEntry {
676
+ id: string;
677
+ agentId: string;
678
+ content: string;
679
+ createdAt: string;
680
+ /** Human-readable provenance: which tool/session wrote this entry. */
681
+ provenance: string;
682
+ }
683
+ interface ProLicenseInfo {
684
+ /** License tier from the verified payload; null when unlicensed. */
685
+ tier: string | null;
686
+ /** ISO expiry timestamp from the verified payload; null when unlicensed. */
687
+ expiresAt: string | null;
688
+ customerId?: string | null;
689
+ }
690
+ interface ProUiApi {
691
+ features(): ProUiFeatures;
692
+ /** Display metadata from the verified license payload — never the key itself. */
693
+ licenseInfo?(): ProLicenseInfo;
694
+ listMemory(): Promise<ProMemoryEntry[]>;
695
+ forgetMemory(id: string): Promise<boolean>;
696
+ /** Streams consult progress via onEvent; resolves when complete. */
697
+ consult?(question: string, onEvent: (event: {
698
+ type: string;
699
+ data: unknown;
700
+ }) => void): Promise<void>;
701
+ }
702
+ interface ProModule {
703
+ name: string;
704
+ connectors?: Connector[];
705
+ /**
706
+ * Handed the resolved config by createRuntime() before any tools or UI are
707
+ * used — the `ui` surface needs it on paths where registerTools never runs
708
+ * (the `ctxfile ui` command has no MCP server).
709
+ */
710
+ init?(config: ResolvedConfig): void;
711
+ registerTools?(server: McpServer, config: ResolvedConfig): void;
712
+ /** null when licensed and active; otherwise a human-readable reason Pro is inactive. */
713
+ licenseStatus(): string | null;
714
+ /** Optional UI delegation surface consumed by the local dashboard server. */
715
+ ui?: ProUiApi;
716
+ }
717
+ declare function loadProModule(): Promise<ProModule | null>;
718
+
719
+ interface KdfParams {
720
+ /** Argon2id opsLimit; default interactive (client-side, per libsodium guidance). */
721
+ opsLimit?: number;
722
+ /** Argon2id memLimit in bytes; default interactive (64 MiB). */
723
+ memLimit?: number;
724
+ }
725
+ declare const MASTER_KEY_BYTES = 32;
726
+ declare function generateSalt(): Promise<Uint8Array>;
727
+ /** Passphrase -> 32-byte master key (Argon2id13). Store the salt and params
728
+ beside the vault metadata; they are not secret. */
729
+ declare function deriveMasterKey(passphrase: string, salt: Uint8Array, params?: KdfParams): Promise<Uint8Array>;
730
+ /** Encrypts one blob: MAGIC || 24-byte nonce || AEAD ciphertext.
731
+ `aad` binds the ciphertext to its slot (use the blob id). */
732
+ declare function encryptBlob(key: Uint8Array, plaintext: Uint8Array, aad: string): Promise<Uint8Array>;
733
+ /** Reverses encryptBlob. Throws on tampering, a wrong key, or a wrong aad. */
734
+ declare function decryptBlob(key: Uint8Array, blob: Uint8Array, aad: string): Promise<Uint8Array>;
735
+ declare function generateRecoveryCode(): Promise<string>;
736
+ /** Canonical form of a recovery code for use as a wrapping secret, so a user
737
+ may retype it with any casing or dashing and still unwrap. Wrap and unwrap
738
+ MUST both pass the code through this. */
739
+ declare function normalizeRecoveryCode(code: string): string;
740
+ /** Wraps the master key under a secondary secret (the recovery code), so a
741
+ vault stays recoverable when the passphrase is lost. The wrapped copy is
742
+ safe to store beside the vault. */
743
+ declare function wrapMasterKey(masterKey: Uint8Array, wrappingSecret: string, salt: Uint8Array, params?: KdfParams): Promise<Uint8Array>;
744
+ declare function unwrapMasterKey(wrapped: Uint8Array, wrappingSecret: string, salt: Uint8Array, params?: KdfParams): Promise<Uint8Array>;
745
+ /** Best-effort scrub of key material once done with it. */
746
+ declare function zeroKey(key: Uint8Array): Promise<void>;
747
+ /** Opaque, stable blob id: a keyed hash of the natural id, so the relay
748
+ learns nothing from ids (harness names, session ids stay private). */
749
+ declare function deriveBlobId(key: Uint8Array, naturalId: string): Promise<string>;
750
+ declare function toBase64(bytes: Uint8Array): Promise<string>;
751
+ declare function fromBase64(text: string): Promise<Uint8Array>;
752
+
753
+ /**
754
+ * RelayStore over HTTP: the client half of the relay's blob API. The wire
755
+ * carries only opaque ids, versions, and base64 ciphertext; the bearer token
756
+ * is the vault token minted at vault create/join.
757
+ */
758
+ interface HttpRelayStoreOptions {
759
+ fetchImpl?: typeof fetch;
760
+ }
761
+ declare class HttpRelayStore implements RelayStore {
762
+ private readonly token;
763
+ private readonly base;
764
+ private readonly fetchImpl;
765
+ constructor(relayUrl: string, token: string, options?: HttpRelayStoreOptions);
766
+ private request;
767
+ list(): Promise<SyncBlobMeta[]>;
768
+ get(id: string): Promise<{
769
+ meta: SyncBlobMeta;
770
+ data: Uint8Array;
771
+ } | null>;
772
+ put(id: string, meta: SyncBlobMeta, data: Uint8Array): Promise<void>;
773
+ }
774
+
775
+ /**
776
+ * The plaintext shapes that travel inside encrypted sync blobs, shared by the
777
+ * local store (export/import) and the relay (which reconstructs a vault view
778
+ * from decrypted payloads to serve the five MCP tools in Standard mode).
779
+ * Every payload carries its own natural identity, so applies are
780
+ * order-independent and never depend on relay-visible ids.
781
+ */
782
+ interface SessionSyncPayload {
783
+ kind: "session";
784
+ harness: string;
785
+ harness_version: string | null;
786
+ session_id: string;
787
+ door: string;
788
+ started_at: string | null;
789
+ ended_at: string | null;
790
+ summary: string;
791
+ key_decisions: string[];
792
+ files_touched: string[];
793
+ open_items: string[];
794
+ thread_title: string | null;
795
+ continues_from: string | null;
796
+ handoff: boolean;
797
+ state: string | null;
798
+ gotchas: string[];
799
+ artifacts: IngestArtifact[];
800
+ suggested_first_prompt: string | null;
801
+ /** Behavior-layer provenance; absent in pre-v1 payloads means manual. */
802
+ trigger?: "auto" | "manual";
803
+ ingested_at: number;
804
+ updated_at: number;
805
+ revision: number;
806
+ deleted: boolean;
807
+ }
808
+ interface ThreadSyncPayload {
809
+ kind: "thread";
810
+ title: string;
811
+ status: string;
812
+ tags: string[];
813
+ created_at: number;
814
+ last_active: number;
815
+ deleted: boolean;
816
+ /** Local auto-capture exclusion, synced so it holds across devices. Absent
817
+ in pre-v1 payloads means public. */
818
+ private?: boolean;
819
+ }
820
+ type SyncPayload = SessionSyncPayload | ThreadSyncPayload;
821
+ declare function parseSyncPayload(payload: Uint8Array): SyncPayload | null;
822
+ declare function sessionPayloadToIngestedSession(payload: SessionSyncPayload, id: number, root?: string): IngestedSession;
823
+ interface VaultView {
824
+ threads: ThreadSummary[];
825
+ /** Live sessions, chronological (oldest first), threadTitle populated. */
826
+ sessions: IngestedSession[];
827
+ }
828
+ /** Rebuilds the queryable view a local IngestStore would give, from nothing
829
+ but decrypted payloads. Tombstoned records are dropped; a session whose
830
+ thread payload never arrived still yields a thread summary. */
831
+ declare function buildVaultView(payloads: SyncPayload[]): VaultView;
832
+
833
+ /**
834
+ * The vault client (M2/M3 seam): create or join a vault on a relay, unlock it
835
+ * with the passphrase, and sync the local store through it. Key design: the
836
+ * blob key is a random data key; the passphrase (and a printed recovery code)
837
+ * merely WRAP it, and the wraps live on the relay as vault metadata, so a new
838
+ * device needs only the vault token plus the passphrase, and a passphrase
839
+ * change is a re-wrap, never a re-encrypt of the world. In Standard mode the
840
+ * data key is additionally enrolled with the relay's keyring for serve-time
841
+ * decryption; in Strict mode it never leaves your devices.
842
+ */
843
+ interface VaultConfig {
844
+ relayUrl: string;
845
+ vaultId: string;
846
+ name: string;
847
+ mode: "standard" | "strict";
848
+ /** Bearer token for this device; treat like a password. */
849
+ token: string;
850
+ }
851
+ interface VaultMeta {
852
+ vault_id: string;
853
+ name: string;
854
+ mode: "standard" | "strict";
855
+ salt_b64: string;
856
+ kdf: {
857
+ ops_limit: number;
858
+ mem_limit: number;
859
+ };
860
+ wrapped_passphrase_b64: string;
861
+ /** Present on device-token reads; used by 'ctxfile vault recover'. */
862
+ wrapped_recovery_b64?: string;
863
+ }
864
+ /** Minimum vault passphrase length. Raised past a throwaway value because the
865
+ KDF salt and parameters are stored as vault metadata: a weak passphrase is
866
+ offline-bruteforceable by anyone who obtains that metadata. */
867
+ declare const MIN_PASSPHRASE_LENGTH = 12;
868
+ declare function assertPassphraseStrength(passphrase: string): void;
869
+ declare const DEFAULT_VAULT_CONFIG_PATH: string;
870
+ declare function loadVaultConfig(configPath?: string): VaultConfig | null;
871
+ declare function saveVaultConfig(config: VaultConfig, configPath?: string): void;
872
+ interface CreateVaultOptions {
873
+ relayUrl: string;
874
+ name: string;
875
+ mode: "standard" | "strict";
876
+ passphrase: string;
877
+ kdf?: KdfParams;
878
+ configPath?: string;
879
+ fetchImpl?: typeof fetch;
880
+ }
881
+ declare function createVault(options: CreateVaultOptions): Promise<{
882
+ config: VaultConfig;
883
+ recoveryCode: string;
884
+ }>;
885
+ interface JoinVaultOptions {
886
+ relayUrl: string;
887
+ token: string;
888
+ passphrase: string;
889
+ configPath?: string;
890
+ fetchImpl?: typeof fetch;
891
+ }
892
+ /** Second device: vault token + passphrase is all it takes; the salt and the
893
+ wrapped key come down from the relay. */
894
+ declare function joinVault(options: JoinVaultOptions): Promise<VaultConfig>;
895
+ interface RecoverVaultOptions {
896
+ relayUrl: string;
897
+ token: string;
898
+ recoveryCode: string;
899
+ newPassphrase: string;
900
+ configPath?: string;
901
+ fetchImpl?: typeof fetch;
902
+ }
903
+ /** Reset a lost passphrase with the printed recovery code. The data key is
904
+ recovered from the recovery wrap, then re-wrapped under the new passphrase
905
+ and a freshly rotated recovery code — nothing is re-encrypted. Requires a
906
+ device token (the recovery code replaces the passphrase factor, not the
907
+ device factor). Returns the new recovery code; the old one stops working. */
908
+ declare function recoverVault(options: RecoverVaultOptions): Promise<{
909
+ config: VaultConfig;
910
+ recoveryCode: string;
911
+ }>;
912
+ declare function fetchVaultMeta(fetchImpl: typeof fetch, relayUrl: string, token: string): Promise<VaultMeta>;
913
+ declare function unlockVault(config: VaultConfig, passphrase: string, fetchImpl?: typeof fetch): Promise<Uint8Array>;
914
+ /** Everything wired: unlock, point the local store at the relay, sync. */
915
+ declare function openVaultSync(config: VaultConfig, passphrase: string, store: IngestStore, root: string, fetchImpl?: typeof fetch): Promise<SyncClient>;
916
+
917
+ declare function isDeniedPath(relPath: string): boolean;
918
+ declare function redactContent(text: string): {
919
+ text: string;
920
+ redactions: number;
921
+ };
922
+
923
+ interface RuntimeOptions {
924
+ /** Pass null to disable snapshot caching (e.g. in tests). */
925
+ cache?: SnapshotCache | null;
926
+ connectors?: Connector[];
927
+ summarizer?: Summarizer | null;
928
+ /** Commercial Pro module, loaded via loadProModule(). */
929
+ pro?: ProModule | null;
930
+ /** Store for agent-reported sessions; pass null to disable (tests). */
931
+ ingest?: IngestStore | null;
932
+ }
933
+
934
+ interface ServerOptions extends RuntimeOptions {
935
+ /** Scope allowlist for this connection; undefined (stdio) means everything. */
936
+ scopes?: ServeScope[];
937
+ }
938
+ declare function createServer(config: ResolvedConfig, options?: ServerOptions): McpServer;
939
+
940
+ declare const VERSION = "0.1.0";
941
+
942
+ export { BEHAVIOR_HARNESSES, type BehaviorHarness, type BehaviorState, type BuildEvent, type BuildExportOptions, type Connector, type ConnectorStatus, type ConsultProviderSpec, type ContextMeta, type ContextObject, type ContextScope, type CreateVaultOptions, DEFAULT_VAULT_CONFIG_PATH, EXPORT_PROFILES, EXPORT_SCHEMA_VERSION, EXPORT_SECTIONS, type ExportEnvelope, type ExportProfile, type ExportSection, type ExportedContext, type ExportedKeyFile, type FetchLike, type GitCommit, type GitState, HttpRelayStore, type HttpRelayStoreOptions, INGEST_SCHEMA_VERSION, type IngestArtifact, type IngestDoor, type IngestInput, type IngestResult, IngestStore, type IngestedSession, type JoinVaultOptions, type KdfParams, type KeyFile, type LoadConfigOptions, type LocalBlobSource, MASTER_KEY_BYTES, MIN_PASSPHRASE_LENGTH, type NotionConnectorOptions, type NotionPage, type OllamaSummarizerOptions, type ProLicenseInfo, type ProMemoryEntry, type ProModule, type ProUiApi, type ProUiFeatures, type RecoverVaultOptions, type RelayStore, type RenderedBehavior, type ResolvedConfig, type SaveSessionInput, type ServeTokenSpec, type ServerOptions, type SessionDigest, type SessionSyncPayload, SnapshotCache, type SnapshotInput, type SnapshotService, type Summarizer, type SyncBlobMeta, SyncClient, type SyncEntry, type SyncPayload, type SyncResult, type ThreadResolution, type ThreadSummary, type ThreadSyncPayload, TokenBudget, type UninstallResult, VERSION, type VaultConfig, type VaultMeta, type VaultView, assertPassphraseStrength, autoCaptureBlocked, buildContext, buildExportEnvelope, buildVaultView, clearBehaviorState, createNotionConnector, createOllamaSummarizer, createServer, createSnapshotService, createVault, decryptBlob, deriveBlobId, deriveMasterKey, detectHarnesses, encryptBlob, estimateTokens, fetchVaultMeta, fileConnector, filterScope, formatIngestErrors, fromBase64, generateRecoveryCode, generateSalt, gitConnector, inferHarnessFromClientName, ingestInputSchema, ingestSessionId, ingestSessionSchema, ingestSourceSchema, ingestToSessionDigest, inspectLicenseKey, installBehavior, isDeniedPath, joinVault, loadCanonicalBehaviors, loadConfig, loadProModule, loadVaultConfig, mergeIngestedSessions, normalizeRecoveryCode, openVaultSync, parseSyncPayload, readBehaviorState, recoverVault, redactContent, renderAllBehaviors, renderBehavior, renderExportMarkdown, renderThreadResume, resolveThread, saveSessionSchema, saveVaultConfig, scoreThreadMatch, sessionPayloadToIngestedSession, toBase64, truncateToTokens, uninstallBehavior, unlockVault, unwrapMasterKey, wrapMasterKey, writeBehaviorState, zeroKey };