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.
- package/README.md +494 -115
- package/dist/src/agent/api.d.ts +2 -0
- package/dist/src/agent/api.js +5 -0
- package/dist/src/agent/index.d.ts +3 -0
- package/dist/src/agent/index.js +2 -0
- package/dist/src/cli/main.js +125 -6
- package/dist/src/cognition/filesystem-observer.d.ts +41 -0
- package/dist/src/cognition/filesystem-observer.js +164 -0
- package/dist/src/cognition/git-observer.d.ts +1 -0
- package/dist/src/cognition/git-observer.js +3 -2
- package/dist/src/cognition/journal.d.ts +4 -1
- package/dist/src/cognition/journal.js +100 -4
- package/dist/src/cognition/migration-v1.js +1 -1
- package/dist/src/cognition/orient.js +4 -3
- package/dist/src/cognition/policy.d.ts +4 -0
- package/dist/src/cognition/policy.js +49 -0
- package/dist/src/cognition/projections.js +15 -1
- package/dist/src/cognition/runtime.d.ts +20 -1
- package/dist/src/cognition/runtime.js +229 -53
- package/dist/src/cognition/types.d.ts +16 -0
- package/dist/src/cognition/v1-bridge.js +3 -2
- package/dist/src/compiler/activity.d.ts +3 -0
- package/dist/src/compiler/activity.js +79 -0
- package/dist/src/compiler/compile.d.ts +2 -0
- package/dist/src/compiler/compile.js +353 -0
- package/dist/src/compiler/expand.d.ts +2 -0
- package/dist/src/compiler/expand.js +187 -0
- package/dist/src/compiler/identity.d.ts +6 -0
- package/dist/src/compiler/identity.js +36 -0
- package/dist/src/compiler/sources.d.ts +19 -0
- package/dist/src/compiler/sources.js +126 -0
- package/dist/src/compiler/types.d.ts +233 -0
- package/dist/src/compiler/types.js +1 -0
- package/dist/src/hooks/claude-code.js +7 -15
- package/dist/src/mcp/main.js +2 -0
- package/dist/src/mcp/server.js +167 -11
- package/dist/src/runtime/runtime.js +10 -0
- package/dist/src/runtime/types.d.ts +1 -1
- package/dist/src/storage/layout.d.ts +1 -0
- package/dist/src/storage/layout.js +3 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/skills/fluffy-context/SKILL.md +10 -3
package/dist/src/agent/api.d.ts
CHANGED
|
@@ -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<{
|
package/dist/src/agent/api.js
CHANGED
|
@@ -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';
|
package/dist/src/agent/index.js
CHANGED
package/dist/src/cli/main.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
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.
|
|
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(),
|