fluffy-context 0.5.0 → 0.7.2

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 (47) hide show
  1. package/README.md +121 -9
  2. package/dist/src/agent/api.d.ts +5 -1
  3. package/dist/src/agent/api.js +41 -12
  4. package/dist/src/agent/index.d.ts +3 -1
  5. package/dist/src/agent/index.js +1 -0
  6. package/dist/src/agent/types.d.ts +21 -0
  7. package/dist/src/cli/main.js +150 -12
  8. package/dist/src/cognition/feedback.d.ts +9 -0
  9. package/dist/src/cognition/feedback.js +30 -0
  10. package/dist/src/cognition/filesystem-observer.d.ts +41 -0
  11. package/dist/src/cognition/filesystem-observer.js +164 -0
  12. package/dist/src/cognition/git-observer.d.ts +1 -0
  13. package/dist/src/cognition/git-observer.js +3 -2
  14. package/dist/src/cognition/journal.d.ts +4 -1
  15. package/dist/src/cognition/journal.js +83 -4
  16. package/dist/src/cognition/migration-v1.js +13 -11
  17. package/dist/src/cognition/orient.d.ts +4 -0
  18. package/dist/src/cognition/orient.js +14 -4
  19. package/dist/src/cognition/policy.d.ts +4 -0
  20. package/dist/src/cognition/policy.js +49 -0
  21. package/dist/src/cognition/projections.js +0 -0
  22. package/dist/src/cognition/retrieval.d.ts +26 -0
  23. package/dist/src/cognition/retrieval.js +273 -0
  24. package/dist/src/cognition/runtime.d.ts +20 -1
  25. package/dist/src/cognition/runtime.js +229 -53
  26. package/dist/src/cognition/types.d.ts +55 -1
  27. package/dist/src/cognition/v1-bridge.js +13 -4
  28. package/dist/src/compiler/activity.d.ts +3 -0
  29. package/dist/src/compiler/activity.js +79 -0
  30. package/dist/src/compiler/compile.d.ts +2 -0
  31. package/dist/src/compiler/compile.js +330 -0
  32. package/dist/src/compiler/sources.d.ts +19 -0
  33. package/dist/src/compiler/sources.js +130 -0
  34. package/dist/src/compiler/types.d.ts +187 -0
  35. package/dist/src/compiler/types.js +1 -0
  36. package/dist/src/hooks/claude-code.js +7 -15
  37. package/dist/src/mcp/main.js +2 -0
  38. package/dist/src/mcp/server.js +2 -0
  39. package/dist/src/runtime/knowledge.d.ts +2 -0
  40. package/dist/src/runtime/knowledge.js +63 -0
  41. package/dist/src/runtime/types.d.ts +19 -0
  42. package/dist/src/storage/layout.d.ts +2 -0
  43. package/dist/src/storage/layout.js +6 -0
  44. package/dist/src/version.d.ts +1 -1
  45. package/dist/src/version.js +1 -1
  46. package/package.json +2 -2
  47. package/skills/fluffy-context/SKILL.md +2 -1
@@ -1,14 +1,20 @@
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';
4
5
  import { importV1, verifyV1Import } from '../cognition/migration-v1.js';
5
- import { disableRuntime, enableRuntime, runtimeStatus, serveRuntime, startRuntime, stopRuntime } from '../cognition/runtime.js';
6
+ import { recordUse } from '../cognition/feedback.js';
7
+ import { verifyJournal } from '../cognition/journal.js';
8
+ import { projectEvents } from '../cognition/projections.js';
9
+ import { disableRuntime, enableRuntime, ensureRuntimeForSession, runtimeStatus, serveRuntime, setFilesystemEvents, startRuntime, stopRuntime } from '../cognition/runtime.js';
10
+ import { loadRuntimePolicy } from '../cognition/policy.js';
6
11
  import { migrationReadiness } from '../cognition/readiness.js';
7
12
  import { runClaudeCodeHook } from '../hooks/claude-code.js';
13
+ import { readGitWorktreeState } from '../git/git-adapter.js';
8
14
  import { inspectClaudeIntegration, installClaudeIntegration } from '../integrations/claude-code.js';
9
15
  import { doctor, status } from '../runtime/diagnostics.js';
10
16
  import { initProject } from '../runtime/init.js';
11
- import { discoverKnowledge, learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend } from '../runtime/knowledge.js';
17
+ import { discoverKnowledge, learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend, transitionKnowledge, transitionDeadend } from '../runtime/knowledge.js';
12
18
  import { addNote, listActivity, listNotes } from '../runtime/notes.js';
13
19
  import { VERSION } from '../version.js';
14
20
  function option(args, name) {
@@ -111,6 +117,21 @@ Options:
111
117
  --knowledge-limit <number> Maximum knowledge matches (default: 10)
112
118
  --deadend-limit <number> Maximum deadend matches (default: 10)
113
119
  --note-limit <number> Maximum open notes (default: 20)`,
120
+ compile: `usage: ctx compile [query] [options]
121
+
122
+ Compile bounded, provider-neutral context with source provenance and selection explanations.
123
+
124
+ Options:
125
+ --path <path> Project path
126
+ --context <id> Context ID
127
+ --scenario <scenario> resume|implementation|debugging|verification|handoff|exploration|unknown
128
+ --scope <scope> Exact consensus scope
129
+ --paths <paths> Comma-separated project-relative paths
130
+ --max-chars <number> Maximum compiled characters
131
+ --knowledge-limit <number> Maximum knowledge matches (default: 10)
132
+ --deadend-limit <number> Maximum deadend matches (default: 10)
133
+ --note-limit <number> Maximum open notes (default: 20)
134
+ --activity-limit <number> Maximum recent activity groups (default: 20)`,
114
135
  agent: `usage: ctx agent serve
115
136
 
116
137
  Start an MCP stdio server for Agent integrations.
@@ -158,16 +179,23 @@ Options:
158
179
  --evidence <items> Comma-separated evidence references`,
159
180
  knowledge: `usage: ctx knowledge [--all] [--path <path>]
160
181
  ctx knowledge verify <knowledge-id> [--path <path>]
182
+ ctx knowledge deprecate|reject <knowledge-id> --reason <text> [--path <path>]
161
183
  ctx knowledge discover <query> [options]
162
184
 
163
- List candidate/verified knowledge, verify one item, or discover matching knowledge.
185
+ List, govern, or discover project knowledge.
164
186
 
165
187
  Options:
166
188
  --all Include unverified items
167
189
  --path <path> Project path`,
168
190
  'knowledge verify': `usage: ctx knowledge verify <knowledge-id> [--path <path>]
169
191
 
170
- Mark a knowledge item as verified.`,
192
+ Mark a candidate knowledge item as verified.`,
193
+ 'knowledge deprecate': `usage: ctx knowledge deprecate <knowledge-id> --reason <text> [--path <path>]
194
+
195
+ Retire a verified knowledge item with an explicit governance reason.`,
196
+ 'knowledge reject': `usage: ctx knowledge reject <knowledge-id> --reason <text> [--path <path>]
197
+
198
+ Reject a candidate knowledge item with an explicit governance reason.`,
171
199
  'knowledge discover': `usage: ctx knowledge discover <query> [options]
172
200
 
173
201
  Find matching project knowledge using deterministic lexical and alias rules.
@@ -181,8 +209,9 @@ Options:
181
209
  --max-chars <number> Maximum text characters per match`,
182
210
  deadend: `usage: ctx deadend [attempt] [options]
183
211
  ctx deadend verify <deadend-id> [--path <path>]
212
+ ctx deadend obsolete|reject <deadend-id> --reason <text> [--path <path>]
184
213
 
185
- Record or verify a candidate deadend.
214
+ Record or govern a candidate deadend.
186
215
 
187
216
  Options:
188
217
  --path <path> Project path
@@ -195,7 +224,25 @@ Options:
195
224
  --evidence <items> Comma-separated evidence references`,
196
225
  'deadend verify': `usage: ctx deadend verify <deadend-id> [--path <path>]
197
226
 
198
- Mark a deadend as verified.`,
227
+ Mark a candidate deadend as verified.`,
228
+ 'deadend obsolete': `usage: ctx deadend obsolete <deadend-id> --reason <text> [--path <path>]
229
+
230
+ Retire a verified deadend with an explicit governance reason.`,
231
+ 'deadend reject': `usage: ctx deadend reject <deadend-id> --reason <text> [--path <path>]
232
+
233
+ Reject a candidate deadend with an explicit governance reason.`,
234
+ feedback: `usage: ctx feedback use knowledge|deadend <id> --event-id <stable-id> [--path <path>]
235
+
236
+ Record explicit use of verified v2 retrieval material.`,
237
+ 'feedback use': `usage: ctx feedback use knowledge|deadend <id> --event-id <stable-id> [--path <path>]
238
+
239
+ Record an idempotent use signal that affects deterministic rank only.`,
240
+ journal: `usage: ctx journal verify [--path <path>]
241
+
242
+ Validate the v2 journal hash chain and replay projections in memory.`,
243
+ 'journal verify': `usage: ctx journal verify [--path <path>]
244
+
245
+ Validate journal integrity without writing derived state.`,
199
246
  deadends: `usage: ctx deadends [--all] [--path <path>]
200
247
 
201
248
  List recorded deadends.`,
@@ -259,6 +306,18 @@ Options:
259
306
  runtime: `usage: ctx runtime enable|disable|start|stop|status [--path <path>]
260
307
 
261
308
  Control the local event-driven Knowledge Runtime after v1 migration verification.`,
309
+ filesystem: `usage: ctx filesystem enable|disable|status [--path <path>]
310
+
311
+ Control whether the Runtime records filtered file metadata changes.`,
312
+ 'filesystem enable': `usage: ctx filesystem enable [--path <path>]
313
+
314
+ Enable filtered filesystem change observation and restart a running Runtime.`,
315
+ 'filesystem disable': `usage: ctx filesystem disable [--path <path>]
316
+
317
+ Disable filesystem change observation and restart a running Runtime.`,
318
+ 'filesystem status': `usage: ctx filesystem status [--path <path>]
319
+
320
+ Show the filesystem observation policy and Runtime status.`,
262
321
  'runtime status': `usage: ctx runtime status [--path <path>]
263
322
 
264
323
  Show the read-only runtime policy, migration and authenticated lease status.
@@ -282,7 +341,7 @@ Stop the local runtime through authenticated loopback IPC.`,
282
341
  Internal runtime daemon entry point.`,
283
342
  };
284
343
  function usage() {
285
- return `usage: ctx init|checkpoint|resume|orient|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime [options]
344
+ return `usage: ctx init|checkpoint|resume|orient|compile|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|journal [options]
286
345
 
287
346
  Run \"ctx <command> --help\" for command details.`;
288
347
  }
@@ -304,11 +363,14 @@ function printHelp(args) {
304
363
  const nested = (command === 'agent' && args[1] === 'serve')
305
364
  || (command === 'hook' && args[1] === 'claude-code')
306
365
  || (command === 'integrate' && args[1] === 'claude')
307
- || (command === 'knowledge' && ['verify', 'discover'].includes(args[1]))
308
- || (command === 'deadend' && args[1] === 'verify')
366
+ || (command === 'knowledge' && ['verify', 'discover', 'deprecate', 'reject'].includes(args[1]))
367
+ || (command === 'deadend' && ['verify', 'obsolete', 'reject'].includes(args[1]))
368
+ || (command === 'feedback' && args[1] === 'use')
369
+ || (command === 'journal' && args[1] === 'verify')
309
370
  || (command === 'note' && ['add', 'list'].includes(args[1]))
310
371
  || (command === 'migrate' && ['v1', 'verify'].includes(args[1]))
311
- || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]));
372
+ || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]))
373
+ || (command === 'filesystem' && ['enable', 'disable', 'status'].includes(args[1]));
312
374
  const key = nested ? `${command} ${args[1]}` : command;
313
375
  const nestedKey = key === 'integrate claude' && (args[2] === 'inspect' || args[2] === 'install') ? `${key} ${args[2]}` : key;
314
376
  const help = HELP[nestedKey];
@@ -373,6 +435,7 @@ async function run(args) {
373
435
  validateOptions(args.slice(1), valueOptions, valueOptions);
374
436
  const query = positionals(args.slice(1), valueOptions);
375
437
  validatePositionals(query, 1, HELP.orient);
438
+ await ensureRuntimeForSession(target, { source: 'cli', timeoutMs: 1_000 }).catch(() => undefined);
376
439
  print(await contextOrient(target, {
377
440
  contextId: option(args, '--context'),
378
441
  query: query[0],
@@ -384,6 +447,25 @@ async function run(args) {
384
447
  }));
385
448
  return;
386
449
  }
450
+ case 'compile': {
451
+ const valueOptions = ['--path', '--context', '--scenario', '--scope', '--paths', '--max-chars', '--knowledge-limit', '--deadend-limit', '--note-limit', '--activity-limit'];
452
+ validateOptions(args.slice(1), valueOptions, valueOptions);
453
+ const query = positionals(args.slice(1), valueOptions);
454
+ validatePositionals(query, 1, HELP.compile);
455
+ print(await compileContext(target, {
456
+ contextId: option(args, '--context'),
457
+ query: query[0],
458
+ scenario: enumOption(args, '--scenario', ['resume', 'implementation', 'debugging', 'verification', 'handoff', 'exploration', 'unknown']),
459
+ scope: option(args, '--scope'),
460
+ paths: listOption(args, '--paths'),
461
+ maxChars: numericOption(args, '--max-chars', 4000),
462
+ knowledgeLimit: numericOption(args, '--knowledge-limit', 10),
463
+ deadendLimit: numericOption(args, '--deadend-limit', 10),
464
+ noteLimit: numericOption(args, '--note-limit', 20),
465
+ activityLimit: numericOption(args, '--activity-limit', 20),
466
+ }));
467
+ return;
468
+ }
387
469
  case 'agent':
388
470
  if (args[1] !== 'serve')
389
471
  throw new Error(HELP.agent);
@@ -423,17 +505,20 @@ async function run(args) {
423
505
  print(await doctor(target));
424
506
  return;
425
507
  case 'learn': {
426
- const valueOptions = ['--path', '--kind', '--scope', '--context', '--snapshot', '--evidence'];
508
+ const valueOptions = ['--path', '--kind', '--scope', '--context', '--snapshot', '--evidence', '--branch-mode', '--paths'];
427
509
  validateOptions(args.slice(1), valueOptions, valueOptions);
428
510
  const statement = positionals(args.slice(1), valueOptions);
429
511
  if (statement.length === 0)
430
512
  throw new Error(HELP.learn);
513
+ const branchMode = enumOption(args, '--branch-mode', ['global', 'exact']) ?? 'global';
514
+ const git = branchMode === 'exact' ? await readGitWorktreeState(target ?? process.cwd()) : null;
431
515
  const input = {
432
516
  kind: option(args, '--kind'),
433
517
  scope: option(args, '--scope'),
434
518
  sourceContextId: option(args, '--context'),
435
519
  sourceSnapshotId: option(args, '--snapshot'),
436
520
  evidence: listOption(args, '--evidence'),
521
+ applicability: { project: true, branchMode, ...(branchMode === 'exact' ? { branches: [git?.ref ?? git?.branch ?? 'detached'], sourceCommit: git?.commit ?? null } : {}), ...(listOption(args, '--paths') ? { paths: listOption(args, '--paths') } : {}) },
437
522
  };
438
523
  print(await learnKnowledge(target, statement.join(' '), input));
439
524
  return;
@@ -466,6 +551,42 @@ async function run(args) {
466
551
  }
467
552
  return;
468
553
  }
554
+ case 'feedback': {
555
+ if (args[1] !== 'use' || !['knowledge', 'deadend'].includes(args[2]))
556
+ throw new Error(HELP.feedback);
557
+ validateOptions(args.slice(3), ['--path', '--event-id'], ['--path', '--event-id']);
558
+ const values = positionals(args.slice(3), ['--path', '--event-id']);
559
+ if (values.length !== 1)
560
+ throw new Error(HELP['feedback use']);
561
+ print(await recordUse(target, { entity: args[2], entityId: values[0], eventId: option(args, '--event-id') ?? '' }));
562
+ return;
563
+ }
564
+ case 'filesystem': {
565
+ if (!['enable', 'disable', 'status'].includes(args[1]))
566
+ throw new Error(HELP.filesystem);
567
+ validateOptions(args.slice(2), ['--path'], ['--path']);
568
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP[`filesystem ${args[1]}`]);
569
+ if (args[1] === 'status') {
570
+ const status = await runtimeStatus(target);
571
+ print({ projectRoot: status.projectRoot, migrated: status.migrated, enabled: status.enabled, running: status.running, filesystemEventsEnabled: status.migrated ? (await loadRuntimePolicy(status.projectRoot)).filesystemEventsEnabled : false });
572
+ }
573
+ else {
574
+ print(await setFilesystemEvents(target, args[1] === 'enable'));
575
+ }
576
+ return;
577
+ }
578
+ case 'journal': {
579
+ if (args[1] !== 'verify')
580
+ throw new Error(HELP.journal);
581
+ validateOptions(args.slice(2), ['--path'], ['--path']);
582
+ validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP['journal verify']);
583
+ const root = target ?? process.cwd();
584
+ const events = await verifyJournal(root);
585
+ const projection = projectEvents(events);
586
+ const last = events.at(-1);
587
+ 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 } });
588
+ return;
589
+ }
469
590
  case 'migrate': {
470
591
  if (args[1] === 'v1') {
471
592
  validateOptions(args.slice(2), ['--dry-run', '--apply', '--path'], ['--path']);
@@ -545,6 +666,13 @@ async function run(args) {
545
666
  throw new Error(HELP['knowledge verify']);
546
667
  print(await verifyKnowledge(target, values[0]));
547
668
  }
669
+ else if (args[1] === 'deprecate' || args[1] === 'reject') {
670
+ validateOptions(args.slice(2), ['--path', '--reason'], ['--path', '--reason']);
671
+ const values = positionals(args.slice(2), ['--path', '--reason']);
672
+ if (values.length !== 1)
673
+ throw new Error(HELP[`knowledge ${args[1]}`]);
674
+ print(await transitionKnowledge(target, values[0], args[1] === 'deprecate' ? 'deprecated' : 'rejected', option(args, '--reason') ?? ''));
675
+ }
548
676
  else {
549
677
  validateOptions(args.slice(1), ['--all', '--path'], ['--path']);
550
678
  validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.knowledge);
@@ -560,16 +688,26 @@ async function run(args) {
560
688
  throw new Error(HELP['deadend verify']);
561
689
  print(await verifyDeadend(target, values[0]));
562
690
  }
691
+ else if (args[1] === 'obsolete' || args[1] === 'reject') {
692
+ validateOptions(args.slice(2), ['--path', '--reason'], ['--path', '--reason']);
693
+ const values = positionals(args.slice(2), ['--path', '--reason']);
694
+ if (values.length !== 1)
695
+ throw new Error(HELP[`deadend ${args[1]}`]);
696
+ print(await transitionDeadend(target, values[0], args[1] === 'obsolete' ? 'obsolete' : 'rejected', option(args, '--reason') ?? ''));
697
+ }
563
698
  else {
564
- const valueOptions = ['--path', '--attempt', '--reason', '-m', '--scope', '--context', '--snapshot', '--evidence'];
699
+ const valueOptions = ['--path', '--attempt', '--reason', '-m', '--scope', '--context', '--snapshot', '--evidence', '--branch-mode', '--paths'];
565
700
  validateOptions(args.slice(1), valueOptions, valueOptions);
566
701
  const values = positionals(args.slice(1), valueOptions);
567
702
  validatePositionals(values, 1, HELP.deadend);
703
+ const branchMode = enumOption(args, '--branch-mode', ['global', 'exact']) ?? 'global';
704
+ const git = branchMode === 'exact' ? await readGitWorktreeState(target ?? process.cwd()) : null;
568
705
  const input = {
569
706
  scope: option(args, '--scope'),
570
707
  sourceContextId: option(args, '--context'),
571
708
  sourceSnapshotId: option(args, '--snapshot'),
572
709
  evidence: listOption(args, '--evidence'),
710
+ applicability: { project: true, branchMode, ...(branchMode === 'exact' ? { branches: [git?.ref ?? git?.branch ?? 'detached'], sourceCommit: git?.commit ?? null } : {}), ...(listOption(args, '--paths') ? { paths: listOption(args, '--paths') } : {}) },
573
711
  };
574
712
  print(await recordDeadend(target, option(args, '--attempt') ?? values[0], option(args, '--reason') ?? option(args, '-m') ?? '', input));
575
713
  }
@@ -0,0 +1,9 @@
1
+ export interface RecordUseInput {
2
+ entity: 'knowledge' | 'deadend';
3
+ entityId: string;
4
+ eventId: string;
5
+ }
6
+ export declare function recordUse(startPath: string | undefined, input: RecordUseInput): Promise<{
7
+ recorded: boolean;
8
+ eventId: string;
9
+ }>;
@@ -0,0 +1,30 @@
1
+ import { resolveProjectRoot } from '../project/project-resolver.js';
2
+ import { readGitWorktreeState } from '../git/git-adapter.js';
3
+ import { appendEvent, readJournal } from './journal.js';
4
+ import { v2Enabled } from './orient.js';
5
+ import { rebuildProjections } from './projections.js';
6
+ import { buildRetrievalProjection } from './retrieval.js';
7
+ export async function recordUse(startPath, input) {
8
+ const projectRoot = await resolveProjectRoot(startPath);
9
+ if (!input.eventId.trim())
10
+ throw new Error('feedback event ID must not be empty');
11
+ if (!await v2Enabled(projectRoot))
12
+ throw new Error('record use requires an enabled v2 runtime');
13
+ const events = await readJournal(projectRoot);
14
+ const entry = buildRetrievalProjection(events).entries[`${input.entity}:${input.entityId}`];
15
+ if (!entry)
16
+ throw new Error(`${input.entity} not found in v2 retrieval: ${input.entityId}`);
17
+ if (entry.status !== 'verified')
18
+ throw new Error(`record use requires verified ${input.entity}: ${input.entityId}`);
19
+ const git = await readGitWorktreeState(projectRoot);
20
+ const result = await appendEvent(projectRoot, {
21
+ type: 'record.used',
22
+ source: { kind: 'cli' },
23
+ idempotencyKey: `record-use:${input.entity}:${input.entityId}:${input.eventId.trim()}`,
24
+ git: { worktreeId: git.worktreeId, branch: git.branch, ref: git.ref, head: git.commit },
25
+ payload: { feedbackId: input.eventId.trim(), entity: input.entity, entityId: input.entityId, outcome: 'used' },
26
+ });
27
+ if (result.appended)
28
+ await rebuildProjections(projectRoot);
29
+ return { recorded: result.appended, eventId: result.event.eventId };
30
+ }
@@ -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;