fluffy-context 0.6.0 → 0.7.6

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.
Files changed (44) hide show
  1. package/README.md +494 -115
  2. package/dist/src/agent/api.d.ts +2 -0
  3. package/dist/src/agent/api.js +5 -0
  4. package/dist/src/agent/index.d.ts +3 -0
  5. package/dist/src/agent/index.js +2 -0
  6. package/dist/src/cli/main.js +125 -6
  7. package/dist/src/cognition/filesystem-observer.d.ts +41 -0
  8. package/dist/src/cognition/filesystem-observer.js +164 -0
  9. package/dist/src/cognition/git-observer.d.ts +1 -0
  10. package/dist/src/cognition/git-observer.js +3 -2
  11. package/dist/src/cognition/journal.d.ts +4 -1
  12. package/dist/src/cognition/journal.js +100 -4
  13. package/dist/src/cognition/migration-v1.js +1 -1
  14. package/dist/src/cognition/orient.js +4 -3
  15. package/dist/src/cognition/policy.d.ts +4 -0
  16. package/dist/src/cognition/policy.js +49 -0
  17. package/dist/src/cognition/projections.js +15 -1
  18. package/dist/src/cognition/runtime.d.ts +20 -1
  19. package/dist/src/cognition/runtime.js +229 -53
  20. package/dist/src/cognition/types.d.ts +16 -0
  21. package/dist/src/cognition/v1-bridge.js +3 -2
  22. package/dist/src/compiler/activity.d.ts +3 -0
  23. package/dist/src/compiler/activity.js +79 -0
  24. package/dist/src/compiler/compile.d.ts +2 -0
  25. package/dist/src/compiler/compile.js +353 -0
  26. package/dist/src/compiler/expand.d.ts +2 -0
  27. package/dist/src/compiler/expand.js +187 -0
  28. package/dist/src/compiler/identity.d.ts +6 -0
  29. package/dist/src/compiler/identity.js +36 -0
  30. package/dist/src/compiler/sources.d.ts +19 -0
  31. package/dist/src/compiler/sources.js +126 -0
  32. package/dist/src/compiler/types.d.ts +233 -0
  33. package/dist/src/compiler/types.js +1 -0
  34. package/dist/src/hooks/claude-code.js +7 -15
  35. package/dist/src/mcp/main.js +2 -0
  36. package/dist/src/mcp/server.js +167 -11
  37. package/dist/src/runtime/runtime.js +10 -0
  38. package/dist/src/runtime/types.d.ts +1 -1
  39. package/dist/src/storage/layout.d.ts +1 -0
  40. package/dist/src/storage/layout.js +3 -0
  41. package/dist/src/version.d.ts +1 -1
  42. package/dist/src/version.js +1 -1
  43. package/package.json +1 -1
  44. package/skills/fluffy-context/SKILL.md +10 -3
@@ -1,8 +1,10 @@
1
1
  import type { AgentLoadOptions, AgentLoadResult, AgentSaveResult, CheckpointInput, ContextSearchOptions, ContextSearchResult } from '../runtime/types.js';
2
2
  import type { ContextOrientOptions, ContextOrientResult, ContextUseInput } from './types.js';
3
+ import type { ContextExpandInput, ContextExpandResult } from '../compiler/types.js';
3
4
  export declare function saveContext(startPath: string | undefined, input: CheckpointInput, options?: {
4
5
  minSaveIntervalMs?: number;
5
6
  }): Promise<AgentSaveResult>;
7
+ export declare function contextExpand(startPath: string | undefined, input: ContextExpandInput): Promise<ContextExpandResult>;
6
8
  export declare function loadContext(startPath: string | undefined, options?: AgentLoadOptions): Promise<AgentLoadResult>;
7
9
  export declare function contextOrient(startPath: string | undefined, options?: ContextOrientOptions): Promise<ContextOrientResult>;
8
10
  export declare function recordContextUse(startPath: string | undefined, input: ContextUseInput): Promise<{
@@ -8,6 +8,7 @@ import { selectV2Orientation } from '../cognition/orient.js';
8
8
  import { discoverV2Deadends, discoverV2Knowledge } from '../cognition/retrieval.js';
9
9
  import { discoverDeadends, discoverKnowledge, listDeadends } from '../runtime/knowledge.js';
10
10
  import { listNotes } from '../runtime/notes.js';
11
+ import { expandContext } from '../compiler/expand.js';
11
12
  function normalized(value) {
12
13
  return value.trim().toLocaleLowerCase();
13
14
  }
@@ -138,6 +139,10 @@ export async function saveContext(startPath, input, options = {}) {
138
139
  const projectRoot = await resolveProjectRoot(startPath);
139
140
  return { projectRoot, result: await checkpoint(projectRoot, input, options) };
140
141
  }
142
+ export async function contextExpand(startPath, input) {
143
+ const projectRoot = await resolveProjectRoot(startPath);
144
+ return expandContext(projectRoot, input);
145
+ }
141
146
  export async function loadContext(startPath, options = {}) {
142
147
  const projectRoot = await resolveProjectRoot(startPath);
143
148
  const result = await resume(projectRoot, options.contextId, options.maxChars ?? 4000);
@@ -1,3 +1,6 @@
1
1
  export * from './api.js';
2
2
  export type { ContextOrientContext, ContextUseInput, ContextOrientExplanation, ContextOrientNoContextResult, ContextOrientOptions, ContextOrientReadyResult, ContextOrientResult, ContextOrientSnapshot, } from './types.js';
3
3
  export type { AgentLoadOptions, AgentLoadResult, AgentSaveResult, ContextSearchHit, ContextSearchOptions, ContextSearchResult, } from '../runtime/types.js';
4
+ export { compileContext } from '../compiler/compile.js';
5
+ export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
6
+ export type { ContextActivityRecord, ContextAnchor, ContextBudget, ContextBudgetReport, ContextCandidate, ContextCandidateSection, ContextCompileOptions, ContextExpandInput, ContextExpandLevel, ContextExpandResult, ContextExclusion, ContextExclusionReason, ContextIntent, ContextIntentSignal, ContextLevel, ContextManifest, ContextProvenance, ContextScenario, ContextSourceDescriptor, ContextTruncation, } from '../compiler/types.js';
@@ -1 +1,3 @@
1
1
  export * from './api.js';
2
+ export { compileContext } from '../compiler/compile.js';
3
+ export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { checkpoint, resume } from '../runtime/runtime.js';
3
3
  import { contextOrient } from '../agent/api.js';
4
+ import { compileContext } from '../compiler/compile.js';
5
+ import { contextExpand } from '../agent/api.js';
4
6
  import { importV1, verifyV1Import } from '../cognition/migration-v1.js';
5
7
  import { recordUse } from '../cognition/feedback.js';
6
- import { readJournal } from '../cognition/journal.js';
8
+ import { verifyJournal } from '../cognition/journal.js';
7
9
  import { projectEvents } from '../cognition/projections.js';
8
- import { disableRuntime, enableRuntime, runtimeStatus, serveRuntime, startRuntime, stopRuntime } from '../cognition/runtime.js';
10
+ import { disableRuntime, enableRuntime, ensureRuntimeForSession, runtimeStatus, serveRuntime, setFilesystemEvents, startRuntime, stopRuntime } from '../cognition/runtime.js';
11
+ import { loadRuntimePolicy } from '../cognition/policy.js';
9
12
  import { migrationReadiness } from '../cognition/readiness.js';
10
13
  import { runClaudeCodeHook } from '../hooks/claude-code.js';
11
14
  import { readGitWorktreeState } from '../git/git-adapter.js';
@@ -115,6 +118,42 @@ Options:
115
118
  --knowledge-limit <number> Maximum knowledge matches (default: 10)
116
119
  --deadend-limit <number> Maximum deadend matches (default: 10)
117
120
  --note-limit <number> Maximum open notes (default: 20)`,
121
+ compile: `usage: ctx compile [query] [options]
122
+
123
+ Compile bounded, provider-neutral context with source provenance and selection explanations.
124
+
125
+ Options:
126
+ --path <path> Project path
127
+ --context <id> Context ID
128
+ --scenario <scenario> resume|implementation|debugging|verification|handoff|exploration|unknown
129
+ --scope <scope> Exact consensus scope
130
+ --paths <paths> Comma-separated project-relative paths
131
+ --max-chars <number> Maximum compiled characters
132
+ --knowledge-limit <number> Maximum knowledge matches (default: 10)
133
+ --deadend-limit <number> Maximum deadend matches (default: 10)
134
+ --note-limit <number> Maximum open notes (default: 20)
135
+ --activity-limit <number> Maximum recent activity groups (default: 20)`,
136
+ expand: `usage: ctx expand [options]
137
+
138
+ Expand one selected compiler candidate without writing project state.
139
+
140
+ Options:
141
+ --path <path> Project path
142
+ --manifest-hash <hash> Manifest identity hash from ctx compile
143
+ --candidate-id <id> Selected candidate ID
144
+ --item-hash <hash> Selected item hash
145
+ --level <level> structured|evidence
146
+ --max-chars <number> Maximum expanded characters
147
+ --compile-max-chars <number> Original compile budget (default: 4000)
148
+ --context <id> Context ID
149
+ --query <text> Original compile query
150
+ --scenario <scenario> resume|implementation|debugging|verification|handoff|exploration|unknown
151
+ --scope <scope> Exact consensus scope
152
+ --paths <paths> Comma-separated project-relative paths
153
+ --knowledge-limit <number> Maximum knowledge matches (default: 10)
154
+ --deadend-limit <number> Maximum deadend matches (default: 10)
155
+ --note-limit <number> Maximum open notes (default: 20)
156
+ --activity-limit <number> Maximum recent activity groups (default: 20)`,
118
157
  agent: `usage: ctx agent serve
119
158
 
120
159
  Start an MCP stdio server for Agent integrations.
@@ -122,7 +161,7 @@ Start an MCP stdio server for Agent integrations.
122
161
  Run "ctx agent serve --help" for details.`,
123
162
  'agent serve': `usage: ctx agent serve
124
163
 
125
- Start an MCP stdio server exposing the context_orient tool.
164
+ Start an MCP stdio server exposing context_orient, context_expand, context_compile, context_resume, context_note_list, context_note_add, and context_checkpoint tools.
126
165
 
127
166
  The server owns standard input/output; do not use it interactively.`,
128
167
  hook: `usage: ctx hook claude-code session-start|user-prompt
@@ -289,6 +328,18 @@ Options:
289
328
  runtime: `usage: ctx runtime enable|disable|start|stop|status [--path <path>]
290
329
 
291
330
  Control the local event-driven Knowledge Runtime after v1 migration verification.`,
331
+ filesystem: `usage: ctx filesystem enable|disable|status [--path <path>]
332
+
333
+ Control whether the Runtime records filtered file metadata changes.`,
334
+ 'filesystem enable': `usage: ctx filesystem enable [--path <path>]
335
+
336
+ Enable filtered filesystem change observation and restart a running Runtime.`,
337
+ 'filesystem disable': `usage: ctx filesystem disable [--path <path>]
338
+
339
+ Disable filesystem change observation and restart a running Runtime.`,
340
+ 'filesystem status': `usage: ctx filesystem status [--path <path>]
341
+
342
+ Show the filesystem observation policy and Runtime status.`,
292
343
  'runtime status': `usage: ctx runtime status [--path <path>]
293
344
 
294
345
  Show the read-only runtime policy, migration and authenticated lease status.
@@ -312,7 +363,7 @@ Stop the local runtime through authenticated loopback IPC.`,
312
363
  Internal runtime daemon entry point.`,
313
364
  };
314
365
  function usage() {
315
- return `usage: ctx init|checkpoint|resume|orient|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|feedback|journal [options]
366
+ return `usage: ctx init|checkpoint|resume|orient|compile|expand|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|journal [options]
316
367
 
317
368
  Run \"ctx <command> --help\" for command details.`;
318
369
  }
@@ -340,7 +391,8 @@ function printHelp(args) {
340
391
  || (command === 'journal' && args[1] === 'verify')
341
392
  || (command === 'note' && ['add', 'list'].includes(args[1]))
342
393
  || (command === 'migrate' && ['v1', 'verify'].includes(args[1]))
343
- || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]));
394
+ || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]))
395
+ || (command === 'filesystem' && ['enable', 'disable', 'status'].includes(args[1]));
344
396
  const key = nested ? `${command} ${args[1]}` : command;
345
397
  const nestedKey = key === 'integrate claude' && (args[2] === 'inspect' || args[2] === 'install') ? `${key} ${args[2]}` : key;
346
398
  const help = HELP[nestedKey];
@@ -405,6 +457,7 @@ async function run(args) {
405
457
  validateOptions(args.slice(1), valueOptions, valueOptions);
406
458
  const query = positionals(args.slice(1), valueOptions);
407
459
  validatePositionals(query, 1, HELP.orient);
460
+ await ensureRuntimeForSession(target, { source: 'cli', timeoutMs: 1_000 }).catch(() => undefined);
408
461
  print(await contextOrient(target, {
409
462
  contextId: option(args, '--context'),
410
463
  query: query[0],
@@ -416,6 +469,58 @@ async function run(args) {
416
469
  }));
417
470
  return;
418
471
  }
472
+ case 'compile': {
473
+ const valueOptions = ['--path', '--context', '--scenario', '--scope', '--paths', '--max-chars', '--knowledge-limit', '--deadend-limit', '--note-limit', '--activity-limit'];
474
+ validateOptions(args.slice(1), valueOptions, valueOptions);
475
+ const query = positionals(args.slice(1), valueOptions);
476
+ validatePositionals(query, 1, HELP.compile);
477
+ print(await compileContext(target, {
478
+ contextId: option(args, '--context'),
479
+ query: query[0],
480
+ scenario: enumOption(args, '--scenario', ['resume', 'implementation', 'debugging', 'verification', 'handoff', 'exploration', 'unknown']),
481
+ scope: option(args, '--scope'),
482
+ paths: listOption(args, '--paths'),
483
+ maxChars: numericOption(args, '--max-chars', 4000),
484
+ knowledgeLimit: numericOption(args, '--knowledge-limit', 10),
485
+ deadendLimit: numericOption(args, '--deadend-limit', 10),
486
+ noteLimit: numericOption(args, '--note-limit', 20),
487
+ activityLimit: numericOption(args, '--activity-limit', 20),
488
+ }));
489
+ return;
490
+ }
491
+ case 'expand': {
492
+ const valueOptions = ['--path', '--manifest-hash', '--candidate-id', '--item-hash', '--level', '--max-chars', '--compile-max-chars', '--context', '--query', '--scenario', '--scope', '--paths', '--knowledge-limit', '--deadend-limit', '--note-limit', '--activity-limit'];
493
+ validateOptions(args.slice(1), valueOptions, valueOptions);
494
+ validatePositionals(positionals(args.slice(1), valueOptions), 0, HELP.expand);
495
+ const level = enumOption(args, '--level', ['structured', 'evidence']);
496
+ if (!level)
497
+ throw new Error('--level is required');
498
+ const manifestHash = option(args, '--manifest-hash');
499
+ if (!manifestHash)
500
+ throw new Error('--manifest-hash is required');
501
+ const compileMaxChars = numericOption(args, '--compile-max-chars', 4000);
502
+ await ensureRuntimeForSession(target, { source: 'cli', timeoutMs: 1_000 }).catch(() => undefined);
503
+ print(await contextExpand(target, {
504
+ manifestHash,
505
+ candidateId: option(args, '--candidate-id'),
506
+ itemHash: option(args, '--item-hash'),
507
+ level: level,
508
+ maxChars: option(args, '--max-chars') === undefined ? undefined : numericOption(args, '--max-chars', 0),
509
+ compile: {
510
+ contextId: option(args, '--context'),
511
+ query: option(args, '--query'),
512
+ scenario: enumOption(args, '--scenario', ['resume', 'implementation', 'debugging', 'verification', 'handoff', 'exploration', 'unknown']),
513
+ scope: option(args, '--scope'),
514
+ paths: listOption(args, '--paths'),
515
+ maxChars: compileMaxChars,
516
+ knowledgeLimit: numericOption(args, '--knowledge-limit', 10),
517
+ deadendLimit: numericOption(args, '--deadend-limit', 10),
518
+ noteLimit: numericOption(args, '--note-limit', 20),
519
+ activityLimit: numericOption(args, '--activity-limit', 20),
520
+ },
521
+ }));
522
+ return;
523
+ }
419
524
  case 'agent':
420
525
  if (args[1] !== 'serve')
421
526
  throw new Error(HELP.agent);
@@ -511,13 +616,27 @@ async function run(args) {
511
616
  print(await recordUse(target, { entity: args[2], entityId: values[0], eventId: option(args, '--event-id') ?? '' }));
512
617
  return;
513
618
  }
619
+ case 'filesystem': {
620
+ if (!['enable', 'disable', 'status'].includes(args[1]))
621
+ throw new Error(HELP.filesystem);
622
+ validateOptions(args.slice(2), ['--path'], ['--path']);
623
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP[`filesystem ${args[1]}`]);
624
+ if (args[1] === 'status') {
625
+ const status = await runtimeStatus(target);
626
+ print({ projectRoot: status.projectRoot, migrated: status.migrated, enabled: status.enabled, running: status.running, filesystemEventsEnabled: status.migrated ? (await loadRuntimePolicy(status.projectRoot)).filesystemEventsEnabled : false });
627
+ }
628
+ else {
629
+ print(await setFilesystemEvents(target, args[1] === 'enable'));
630
+ }
631
+ return;
632
+ }
514
633
  case 'journal': {
515
634
  if (args[1] !== 'verify')
516
635
  throw new Error(HELP.journal);
517
636
  validateOptions(args.slice(2), ['--path'], ['--path']);
518
637
  validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP['journal verify']);
519
638
  const root = target ?? process.cwd();
520
- const events = await readJournal(root);
639
+ const events = await verifyJournal(root);
521
640
  const projection = projectEvents(events);
522
641
  const last = events.at(-1);
523
642
  print({ verified: true, eventCount: events.length, cursor: { eventId: last?.eventId ?? null, hash: last?.hash ?? null }, counts: { contexts: Object.keys(projection.contexts).length, notes: Object.keys(projection.notes).length, knowledge: Object.keys(projection.knowledge).length, deadends: Object.keys(projection.deadends).length } });
@@ -0,0 +1,41 @@
1
+ import type { CognitionGitAnchor } from './types.js';
2
+ export type WatchCallback = (eventType: string, filename: string | Buffer | null) => void;
3
+ export type WatchFactory = (projectRoot: string, callback: WatchCallback) => {
4
+ close(): void;
5
+ };
6
+ export interface FilesystemObserverOptions {
7
+ anchor?: () => CognitionGitAnchor;
8
+ debounceMs?: number;
9
+ maxPaths?: number;
10
+ maxPayloadBytes?: number;
11
+ watchFactory?: WatchFactory;
12
+ onChanged?: (result: FilesystemObservationResult) => void;
13
+ }
14
+ export interface FilesystemObservationResult {
15
+ changed: boolean;
16
+ paths: string[];
17
+ }
18
+ export declare class FilesystemObserver {
19
+ private readonly projectRoot;
20
+ private readonly debounceMs;
21
+ private readonly maxPaths;
22
+ private readonly maxPayloadBytes;
23
+ private readonly anchor;
24
+ private readonly watchFactory;
25
+ private readonly onChanged;
26
+ private readonly pendingPaths;
27
+ private readonly known;
28
+ private watcher;
29
+ private timer;
30
+ private pending;
31
+ private closed;
32
+ constructor(projectRoot: string, options?: FilesystemObserverOptions);
33
+ start(): void;
34
+ stop(): void;
35
+ observeNow(): Promise<FilesystemObservationResult>;
36
+ flush(): Promise<FilesystemObservationResult>;
37
+ drain(): Promise<void>;
38
+ close(): void;
39
+ private scheduleFlush;
40
+ private process;
41
+ }
@@ -0,0 +1,164 @@
1
+ import { watch } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { filterPaths } from '../capture/context-filter.js';
5
+ import { appendEvent } from './journal.js';
6
+ const defaultDebounceMs = 250;
7
+ const defaultMaxPaths = 100;
8
+ const defaultMaxPayloadBytes = 16 * 1024;
9
+ function defaultWatchFactory(projectRoot, callback) {
10
+ return watch(projectRoot, { recursive: true }, callback);
11
+ }
12
+ function normalizeCandidate(projectRoot, filename) {
13
+ if (filename === null)
14
+ return null;
15
+ const value = filename.toString().replaceAll('\\', '/').replace(/^\.\//, '');
16
+ if (!value || value.startsWith('/') || /^[A-Za-z]:\//.test(value))
17
+ return null;
18
+ const absolute = resolve(projectRoot, value);
19
+ const relative = absolute.slice(resolve(projectRoot).length).replaceAll('\\', '/').replace(/^\/+/, '');
20
+ return relative || null;
21
+ }
22
+ function metadataEqual(left, right) {
23
+ return left?.sizeBytes === right.sizeBytes && left.mtimeMs === right.mtimeMs;
24
+ }
25
+ function payloadSize(payload) {
26
+ return Buffer.byteLength(JSON.stringify(payload), 'utf8');
27
+ }
28
+ export class FilesystemObserver {
29
+ projectRoot;
30
+ debounceMs;
31
+ maxPaths;
32
+ maxPayloadBytes;
33
+ anchor;
34
+ watchFactory;
35
+ onChanged;
36
+ pendingPaths = new Map();
37
+ known = new Map();
38
+ watcher = null;
39
+ timer = null;
40
+ pending = Promise.resolve({ changed: false, paths: [] });
41
+ closed = false;
42
+ constructor(projectRoot, options = {}) {
43
+ this.projectRoot = projectRoot;
44
+ this.debounceMs = options.debounceMs ?? defaultDebounceMs;
45
+ this.maxPaths = options.maxPaths ?? defaultMaxPaths;
46
+ this.maxPayloadBytes = options.maxPayloadBytes ?? defaultMaxPayloadBytes;
47
+ this.anchor = options.anchor ?? (() => ({ worktreeId: null, branch: null, ref: null, head: null }));
48
+ this.watchFactory = options.watchFactory ?? defaultWatchFactory;
49
+ this.onChanged = options.onChanged;
50
+ }
51
+ start() {
52
+ if (this.closed || this.watcher)
53
+ return;
54
+ this.watcher = this.watchFactory(this.projectRoot, (eventType, filename) => {
55
+ const relative = normalizeCandidate(this.projectRoot, filename);
56
+ if (relative)
57
+ this.pendingPaths.set(relative, eventType);
58
+ this.scheduleFlush();
59
+ });
60
+ }
61
+ stop() {
62
+ if (this.timer)
63
+ clearTimeout(this.timer);
64
+ this.timer = null;
65
+ this.watcher?.close();
66
+ this.watcher = null;
67
+ this.closed = true;
68
+ }
69
+ observeNow() {
70
+ return this.flush();
71
+ }
72
+ async flush() {
73
+ if (this.timer)
74
+ clearTimeout(this.timer);
75
+ this.timer = null;
76
+ const paths = [...this.pendingPaths.entries()];
77
+ this.pendingPaths.clear();
78
+ if (paths.length === 0)
79
+ return { changed: false, paths: [] };
80
+ const operation = this.pending.then(() => this.process(paths), () => this.process(paths));
81
+ this.pending = operation;
82
+ return operation;
83
+ }
84
+ async drain() {
85
+ await this.pending;
86
+ if (this.pendingPaths.size > 0)
87
+ await this.flush();
88
+ await this.pending;
89
+ }
90
+ close() {
91
+ this.stop();
92
+ }
93
+ scheduleFlush(delay = this.debounceMs) {
94
+ if (this.timer || this.closed)
95
+ return;
96
+ this.timer = setTimeout(() => {
97
+ this.timer = null;
98
+ void this.flush().catch(() => undefined);
99
+ }, delay);
100
+ }
101
+ async process(paths) {
102
+ const filtered = await filterPaths(this.projectRoot, paths.map(([relative]) => relative));
103
+ const eventTypes = new Map(paths);
104
+ const changes = [];
105
+ for (const relative of filtered.slice(0, this.maxPaths)) {
106
+ const absolute = resolve(this.projectRoot, relative);
107
+ let current = null;
108
+ try {
109
+ const value = await stat(absolute);
110
+ if (!value.isFile())
111
+ continue;
112
+ current = { sizeBytes: value.size, mtimeMs: value.mtimeMs };
113
+ }
114
+ catch {
115
+ current = null;
116
+ }
117
+ const previous = this.known.get(relative);
118
+ if (current && metadataEqual(previous, current))
119
+ continue;
120
+ if (!current && !previous)
121
+ continue;
122
+ if (current)
123
+ this.known.set(relative, current);
124
+ else
125
+ this.known.delete(relative);
126
+ changes.push({
127
+ path: relative,
128
+ kind: current ? (previous ? 'modified' : eventTypes.get(relative) === 'rename' ? 'created' : 'modified') : 'deleted',
129
+ fileType: 'file',
130
+ ...(current ? { sizeBytes: current.sizeBytes, mtimeMs: current.mtimeMs } : {}),
131
+ });
132
+ }
133
+ if (changes.length === 0)
134
+ return { changed: false, paths: [] };
135
+ const limitedChanges = changes.slice(0, this.maxPaths);
136
+ const basePayload = {
137
+ schemaVersion: 1,
138
+ paths: limitedChanges.map((change) => change.path),
139
+ changes: limitedChanges,
140
+ pathCount: limitedChanges.length,
141
+ coalesced: paths.length > 1,
142
+ truncated: filtered.length > limitedChanges.length || payloadSize({ changes: limitedChanges }) > this.maxPayloadBytes,
143
+ };
144
+ while (basePayload.changes.length > 1 && payloadSize(basePayload) > this.maxPayloadBytes) {
145
+ basePayload.changes.pop();
146
+ basePayload.paths.pop();
147
+ basePayload.pathCount = basePayload.changes.length;
148
+ basePayload.truncated = true;
149
+ }
150
+ if (basePayload.changes.length === 0 || payloadSize(basePayload) > this.maxPayloadBytes)
151
+ return { changed: false, paths: [] };
152
+ const result = await appendEvent(this.projectRoot, {
153
+ type: 'workspace.paths.changed',
154
+ source: { kind: 'fs-observer' },
155
+ idempotencyKey: `fs:paths:${basePayload.paths.join(',')}:${basePayload.changes.map((change) => `${change.kind}:${change.sizeBytes ?? ''}:${change.mtimeMs ?? ''}`).join(',')}`,
156
+ git: this.anchor(),
157
+ payload: basePayload,
158
+ });
159
+ const observation = { changed: result.appended, paths: basePayload.paths };
160
+ if (observation.changed)
161
+ this.onChanged?.(observation);
162
+ return observation;
163
+ }
164
+ }
@@ -4,6 +4,7 @@ export declare class GitObserver {
4
4
  private previous;
5
5
  private pending;
6
6
  constructor(projectRoot: string);
7
+ get current(): GitWorktreeState | null;
7
8
  observeNow(): Promise<{
8
9
  state: GitWorktreeState;
9
10
  changed: boolean;
@@ -1,6 +1,5 @@
1
1
  import { readGitWorktreeState } from '../git/git-adapter.js';
2
2
  import { appendEvent } from './journal.js';
3
- import { rebuildProjections } from './projections.js';
4
3
  function anchor(state) {
5
4
  return { worktreeId: state.worktreeId, branch: state.branch, ref: state.ref, head: state.commit };
6
5
  }
@@ -14,6 +13,9 @@ export class GitObserver {
14
13
  constructor(projectRoot) {
15
14
  this.projectRoot = projectRoot;
16
15
  }
16
+ get current() {
17
+ return this.previous;
18
+ }
17
19
  observeNow() {
18
20
  const observation = this.pending.then(() => this.observe());
19
21
  this.pending = observation.then(() => undefined, () => undefined);
@@ -55,7 +57,6 @@ export class GitObserver {
55
57
  });
56
58
  }
57
59
  this.previous = state;
58
- await rebuildProjections(this.projectRoot);
59
60
  return { state, changed: true };
60
61
  }
61
62
  }
@@ -17,7 +17,10 @@ export interface AppendEventInput<T = Record<string, unknown>> {
17
17
  payload: T;
18
18
  }
19
19
  export declare function eventHash<T>(event: Omit<CognitionEvent<T>, 'hash'>): string;
20
- export declare function readJournal(startPath?: string): Promise<CognitionEvent[]>;
20
+ export declare function readJournal(startPath?: string, options?: {
21
+ allowPartialTail?: boolean;
22
+ }): Promise<CognitionEvent[]>;
23
+ export declare function verifyJournal(startPath?: string): Promise<CognitionEvent[]>;
21
24
  export declare function appendEvent<T>(startPath: string | undefined, input: AppendEventInput<T>): Promise<{
22
25
  event: CognitionEvent<T>;
23
26
  appended: boolean;
@@ -5,6 +5,15 @@ import { resolveProjectRoot } from '../project/project-resolver.js';
5
5
  import { loadManifest } from '../runtime/init.js';
6
6
  import { withLock } from '../storage/lock.js';
7
7
  import { cognitionJournalDirectory, locksDirectory } from '../storage/layout.js';
8
+ const eventTypes = new Set([
9
+ 'runtime.enabled', 'runtime.started', 'runtime.stopped', 'host.session.started', 'host.prompt.submitted',
10
+ 'git.state.observed', 'git.branch.changed', 'git.head.changed', 'workspace.paths.changed',
11
+ 'context.checkpoint.imported', 'context.state.recorded', 'note.recorded', 'note.resolved', 'note.absorbed',
12
+ 'knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected',
13
+ 'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected', 'record.used',
14
+ 'projection.advanced', 'projection.failed',
15
+ ]);
16
+ const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'git-observer', 'fs-observer', 'migration']);
8
17
  const segmentName = '00000001.ndjson';
9
18
  function canonical(value) {
10
19
  if (value === null || typeof value !== 'object')
@@ -33,6 +42,65 @@ async function journalFiles(projectRoot) {
33
42
  throw error;
34
43
  }
35
44
  }
45
+ function isRecord(value) {
46
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
47
+ }
48
+ function validGitAnchor(value) {
49
+ return isRecord(value)
50
+ && (typeof value.worktreeId === 'string' || value.worktreeId === null)
51
+ && (typeof value.branch === 'string' || value.branch === null)
52
+ && (typeof value.ref === 'string' || value.ref === null)
53
+ && (typeof value.head === 'string' || value.head === null);
54
+ }
55
+ function validIsoDate(value) {
56
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
57
+ }
58
+ function validatePayloadBoundary(input) {
59
+ const payload = input.payload;
60
+ if (input.type === 'host.prompt.submitted' && (typeof payload.intent !== 'string' || payload.intent.trim().length === 0)) {
61
+ throw new Error('host prompt intent must not be empty');
62
+ }
63
+ if (['note.recorded', 'note.resolved', 'note.absorbed'].includes(input.type)) {
64
+ const record = payload.record;
65
+ if (!isRecord(record) || typeof record.message !== 'string' || record.message.trim().length === 0)
66
+ throw new Error('note message must not be empty');
67
+ }
68
+ if (input.type === 'workspace.paths.changed') {
69
+ const changes = payload.changes;
70
+ if (!Array.isArray(changes) || changes.length === 0)
71
+ throw new Error('workspace activity must contain changes');
72
+ }
73
+ }
74
+ function validateEventShape(event, projectId, file, line) {
75
+ if (!isRecord(event)
76
+ || event.schemaVersion !== 2
77
+ || typeof event.eventId !== 'string'
78
+ || event.eventId.length === 0
79
+ || typeof event.type !== 'string'
80
+ || !eventTypes.has(event.type)
81
+ || !validIsoDate(event.occurredAt)
82
+ || !validIsoDate(event.observedAt)
83
+ || event.projectId !== projectId
84
+ || !isRecord(event.source)
85
+ || typeof event.source.kind !== 'string'
86
+ || !sourceKinds.has(event.source.kind)
87
+ || (event.source.instanceId !== undefined && typeof event.source.instanceId !== 'string')
88
+ || (event.correlationId !== undefined && typeof event.correlationId !== 'string')
89
+ || typeof event.idempotencyKey !== 'string'
90
+ || event.idempotencyKey.length === 0
91
+ || !validGitAnchor(event.git)
92
+ || !isRecord(event.privacy)
93
+ || (event.privacy.classification !== 'metadata' && event.privacy.classification !== 'redacted-text')
94
+ || typeof event.privacy.redactionCount !== 'number'
95
+ || !Number.isSafeInteger(event.privacy.redactionCount)
96
+ || event.privacy.redactionCount < 0
97
+ || !isRecord(event.payload)
98
+ || (event.previousHash !== null && typeof event.previousHash !== 'string')
99
+ || typeof event.hash !== 'string'
100
+ || event.hash.length === 0) {
101
+ throw new Error(`invalid journal record schema at ${file}:${line}`);
102
+ }
103
+ }
36
104
  async function recoverPartialTail(projectRoot) {
37
105
  const files = await journalFiles(projectRoot);
38
106
  const file = files.at(-1);
@@ -46,13 +114,15 @@ async function recoverPartialTail(projectRoot) {
46
114
  const tail = content.slice(boundary + 1);
47
115
  try {
48
116
  JSON.parse(tail);
117
+ await appendFile(filePath, '\n', 'utf8');
49
118
  }
50
119
  catch {
51
120
  await truncate(filePath, boundary < 0 ? 0 : boundary + 1);
52
121
  }
53
122
  }
54
- export async function readJournal(startPath) {
123
+ export async function readJournal(startPath, options = {}) {
55
124
  const projectRoot = await resolveProjectRoot(startPath);
125
+ const projectId = (await loadManifest(projectRoot)).projectId;
56
126
  const events = [];
57
127
  let previousHash = null;
58
128
  for (const file of await journalFiles(projectRoot)) {
@@ -67,12 +137,13 @@ export async function readJournal(startPath) {
67
137
  event = JSON.parse(line);
68
138
  }
69
139
  catch (error) {
70
- if (index === lines.length - 1 && !content.endsWith('\n'))
140
+ if (options.allowPartialTail !== false && index === lines.length - 1 && !content.endsWith('\n'))
71
141
  break;
72
142
  throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
73
143
  }
144
+ validateEventShape(event, projectId, file, index + 1);
74
145
  const { hash, ...withoutHash } = event;
75
- if (event.schemaVersion !== 2 || !event.eventId || event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
146
+ if (event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
76
147
  throw new Error(`invalid journal hash chain at ${file}:${index + 1}`);
77
148
  }
78
149
  previousHash = event.hash;
@@ -81,14 +152,39 @@ export async function readJournal(startPath) {
81
152
  }
82
153
  return events;
83
154
  }
155
+ export async function verifyJournal(startPath) {
156
+ return readJournal(startPath, { allowPartialTail: false });
157
+ }
84
158
  export async function appendEvent(startPath, input) {
159
+ validatePayloadBoundary(input);
85
160
  const projectRoot = await resolveProjectRoot(startPath);
86
161
  return withLock(`${locksDirectory(projectRoot)}/cognition-journal.lock`, async () => {
87
162
  await recoverPartialTail(projectRoot);
88
163
  const existing = await readJournal(projectRoot);
89
164
  const duplicate = existing.find((event) => event.idempotencyKey === input.idempotencyKey);
90
- if (duplicate)
165
+ if (duplicate) {
166
+ const candidate = {
167
+ type: input.type,
168
+ source: input.source ?? { kind: 'cli' },
169
+ correlationId: input.correlationId,
170
+ idempotencyKey: input.idempotencyKey,
171
+ git: input.git ?? { worktreeId: null, branch: null, ref: null, head: null },
172
+ privacy: input.privacy ?? { classification: 'metadata', redactionCount: 0 },
173
+ payload: input.payload,
174
+ };
175
+ const existingSemantic = {
176
+ type: duplicate.type,
177
+ source: duplicate.source,
178
+ correlationId: duplicate.correlationId,
179
+ idempotencyKey: duplicate.idempotencyKey,
180
+ git: duplicate.git,
181
+ privacy: duplicate.privacy,
182
+ payload: duplicate.payload,
183
+ };
184
+ if (canonical(candidate) !== canonical(existingSemantic))
185
+ throw new Error(`idempotency key conflict: ${input.idempotencyKey}`);
91
186
  return { event: duplicate, appended: false };
187
+ }
92
188
  const now = new Date().toISOString();
93
189
  const eventWithoutHash = {
94
190
  eventId: input.eventId ?? eventId(),