fluffy-context 0.8.0 → 1.0.0-beta.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.
- package/CHANGELOG.md +44 -0
- package/README.md +119 -604
- package/ROADMAP-1.0.md +74 -0
- package/dist/src/agent/index.d.ts +4 -0
- package/dist/src/agent/index.js +4 -0
- package/dist/src/capture/context-filter.js +2 -0
- package/dist/src/cli/main.js +71 -10
- package/dist/src/cli/progress.d.ts +2 -0
- package/dist/src/cli/progress.js +21 -0
- package/dist/src/cognition/admission.d.ts +8 -0
- package/dist/src/cognition/admission.js +24 -0
- package/dist/src/cognition/import-memory.d.ts +18 -0
- package/dist/src/cognition/import-memory.js +124 -0
- package/dist/src/cognition/journal-segments.d.ts +11 -0
- package/dist/src/cognition/journal-segments.js +129 -0
- package/dist/src/cognition/journal.d.ts +16 -0
- package/dist/src/cognition/journal.js +62 -19
- package/dist/src/cognition/migration-v1.d.ts +2 -1
- package/dist/src/cognition/migration-v1.js +51 -7
- package/dist/src/cognition/progress.d.ts +7 -0
- package/dist/src/cognition/progress.js +1 -0
- package/dist/src/cognition/readiness.d.ts +2 -1
- package/dist/src/cognition/readiness.js +4 -2
- package/dist/src/cognition/runtime.js +4 -0
- package/dist/src/cognition/usage-report.js +30 -15
- package/dist/src/compiler/compile.js +29 -28
- package/dist/src/compiler/inject.d.ts +23 -0
- package/dist/src/compiler/inject.js +27 -0
- package/dist/src/hooks/claude-code.js +17 -56
- package/dist/src/integrations/claude-code.js +30 -8
- package/dist/src/mcp/server.js +26 -0
- package/dist/src/runtime/knowledge.js +57 -8
- package/dist/src/runtime/setup.d.ts +27 -0
- package/dist/src/runtime/setup.js +85 -0
- package/dist/src/runtime/upgrade.d.ts +63 -0
- package/dist/src/runtime/upgrade.js +175 -0
- package/dist/src/storage/lock.js +19 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +4 -1
- package/skills/fluffy-context/SKILL.md +17 -5
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import { appendFile,
|
|
2
|
+
import { appendFile, readFile, truncate } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
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
|
+
import { appendJournalLine, archiveSegment, journalFiles, journalStorage, readSegment } from './journal-segments.js';
|
|
9
|
+
import { loadRuntimePolicy } from './policy.js';
|
|
10
|
+
import { recordAdmissionDrop } from './admission.js';
|
|
8
11
|
const eventTypes = new Set([
|
|
9
12
|
'runtime.enabled', 'runtime.started', 'runtime.stopped', 'host.session.started', 'host.prompt.submitted',
|
|
10
13
|
'git.state.observed', 'git.branch.changed', 'git.head.changed', 'workspace.paths.changed',
|
|
@@ -18,7 +21,6 @@ const eventTypes = new Set([
|
|
|
18
21
|
'projection.advanced', 'projection.failed',
|
|
19
22
|
]);
|
|
20
23
|
const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'agent', 'git-observer', 'fs-observer', 'migration']);
|
|
21
|
-
const segmentName = '00000001.ndjson';
|
|
22
24
|
function canonical(value) {
|
|
23
25
|
if (value === null || typeof value !== 'object')
|
|
24
26
|
return JSON.stringify(value);
|
|
@@ -36,16 +38,6 @@ function eventId() {
|
|
|
36
38
|
export function eventHash(event) {
|
|
37
39
|
return digest(event);
|
|
38
40
|
}
|
|
39
|
-
async function journalFiles(projectRoot) {
|
|
40
|
-
try {
|
|
41
|
-
return (await readdir(cognitionJournalDirectory(projectRoot))).filter((file) => file.endsWith('.ndjson')).sort();
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
|
|
45
|
-
return [];
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
41
|
function isRecord(value) {
|
|
50
42
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
51
43
|
}
|
|
@@ -244,7 +236,7 @@ function validateEventShape(event, projectId, file, line) {
|
|
|
244
236
|
async function recoverPartialTail(projectRoot) {
|
|
245
237
|
const files = await journalFiles(projectRoot);
|
|
246
238
|
const file = files.at(-1);
|
|
247
|
-
if (!file)
|
|
239
|
+
if (!file || file.endsWith('.gz'))
|
|
248
240
|
return;
|
|
249
241
|
const filePath = path.join(cognitionJournalDirectory(projectRoot), file);
|
|
250
242
|
const content = await readFile(filePath, 'utf8');
|
|
@@ -257,7 +249,7 @@ async function recoverPartialTail(projectRoot) {
|
|
|
257
249
|
await appendFile(filePath, '\n', 'utf8');
|
|
258
250
|
}
|
|
259
251
|
catch {
|
|
260
|
-
await truncate(filePath, boundary < 0 ? 0 : boundary + 1);
|
|
252
|
+
await truncate(filePath, boundary < 0 ? 0 : Buffer.byteLength(content.slice(0, boundary + 1), 'utf8'));
|
|
261
253
|
}
|
|
262
254
|
}
|
|
263
255
|
export async function readJournal(startPath, options = {}) {
|
|
@@ -265,8 +257,11 @@ export async function readJournal(startPath, options = {}) {
|
|
|
265
257
|
const projectId = (await loadManifest(projectRoot)).projectId;
|
|
266
258
|
const events = [];
|
|
267
259
|
let previousHash = null;
|
|
268
|
-
|
|
269
|
-
|
|
260
|
+
const files = await journalFiles(projectRoot);
|
|
261
|
+
options.onProgress?.({ phase: 'verify-journal', completed: 0, total: files.length, unit: 'segments' });
|
|
262
|
+
let completed = 0;
|
|
263
|
+
for (const file of files) {
|
|
264
|
+
const content = (await readSegment(projectRoot, file)).toString('utf8');
|
|
270
265
|
const lines = content.split('\n');
|
|
271
266
|
for (let index = 0; index < lines.length; index += 1) {
|
|
272
267
|
const line = lines[index];
|
|
@@ -277,7 +272,7 @@ export async function readJournal(startPath, options = {}) {
|
|
|
277
272
|
event = JSON.parse(line);
|
|
278
273
|
}
|
|
279
274
|
catch (error) {
|
|
280
|
-
if (options.allowPartialTail !== false && index === lines.length - 1 && !content.endsWith('\n'))
|
|
275
|
+
if (options.allowPartialTail !== false && file === files.at(-1) && !file.endsWith('.gz') && index === lines.length - 1 && !content.endsWith('\n'))
|
|
281
276
|
break;
|
|
282
277
|
throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
|
|
283
278
|
}
|
|
@@ -290,12 +285,46 @@ export async function readJournal(startPath, options = {}) {
|
|
|
290
285
|
previousHash = event.hash;
|
|
291
286
|
events.push(event);
|
|
292
287
|
}
|
|
288
|
+
options.onProgress?.({ phase: 'verify-journal', completed: ++completed, total: files.length, unit: 'segments' });
|
|
293
289
|
}
|
|
294
290
|
return events;
|
|
295
291
|
}
|
|
296
292
|
export async function verifyJournal(startPath) {
|
|
297
293
|
return readJournal(startPath, { allowPartialTail: false });
|
|
298
294
|
}
|
|
295
|
+
export async function compactJournal(startPath, options = {}) {
|
|
296
|
+
const root = await resolveProjectRoot(startPath);
|
|
297
|
+
const minBytes = options.minBytes ?? 1;
|
|
298
|
+
if (!Number.isSafeInteger(minBytes) || minBytes < 0)
|
|
299
|
+
throw new Error('minBytes must be a non-negative integer');
|
|
300
|
+
const operation = async () => {
|
|
301
|
+
const events = await readJournal(root, { allowPartialTail: false, onProgress: options.onProgress });
|
|
302
|
+
const before = await journalStorage(root);
|
|
303
|
+
const eligible = [];
|
|
304
|
+
for (const file of await journalFiles(root)) {
|
|
305
|
+
if (!file.endsWith('.gz') && (await readSegment(root, file)).length >= minBytes)
|
|
306
|
+
eligible.push(file);
|
|
307
|
+
}
|
|
308
|
+
if (options.apply) {
|
|
309
|
+
options.onProgress?.({ phase: 'archive', completed: 0, total: eligible.length, unit: 'segments' });
|
|
310
|
+
for (const [index, file] of eligible.entries()) {
|
|
311
|
+
await archiveSegment(root, file);
|
|
312
|
+
options.onProgress?.({ phase: 'archive', completed: index + 1, total: eligible.length, unit: 'segments' });
|
|
313
|
+
}
|
|
314
|
+
// Complete deletion after an interrupted publication, but only after validation.
|
|
315
|
+
for (const file of await journalFiles(root))
|
|
316
|
+
if (file.endsWith('.gz'))
|
|
317
|
+
await archiveSegment(root, file);
|
|
318
|
+
const replay = await readJournal(root, { allowPartialTail: false, onProgress: options.onProgress });
|
|
319
|
+
if (replay.length !== events.length || replay.at(-1)?.hash !== events.at(-1)?.hash)
|
|
320
|
+
throw new Error('journal replay changed during compaction');
|
|
321
|
+
}
|
|
322
|
+
const after = options.apply ? await journalStorage(root) : before;
|
|
323
|
+
return { applied: options.apply === true, eligible, events: events.length, headHash: events.at(-1)?.hash ?? null,
|
|
324
|
+
bytesBefore: before.bytes, bytesAfter: after.bytes, savedBytes: before.bytes - after.bytes, archives: after.archives };
|
|
325
|
+
};
|
|
326
|
+
return options.apply ? withLock(`${locksDirectory(root)}/cognition-journal.lock`, operation) : operation();
|
|
327
|
+
}
|
|
299
328
|
export async function appendEvent(startPath, input) {
|
|
300
329
|
validatePayloadBoundary(input);
|
|
301
330
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
@@ -343,8 +372,22 @@ export async function appendEvent(startPath, input) {
|
|
|
343
372
|
previousHash: existing.at(-1)?.hash ?? null,
|
|
344
373
|
};
|
|
345
374
|
const event = { ...eventWithoutHash, hash: eventHash(eventWithoutHash) };
|
|
346
|
-
|
|
347
|
-
|
|
375
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
376
|
+
if (['workspace.paths.changed', 'host.prompt.submitted', 'usage.operation.completed'].includes(input.type)) {
|
|
377
|
+
let limit;
|
|
378
|
+
try {
|
|
379
|
+
limit = (await loadRuntimePolicy(projectRoot)).retentionMaxBytes;
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (error.code !== 'ENOENT')
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
if (limit !== undefined && (await journalStorage(projectRoot)).bytes + Buffer.byteLength(line) > limit) {
|
|
386
|
+
await recordAdmissionDrop(projectRoot, input.type);
|
|
387
|
+
throw new Error('observation budget exceeded; semantic records remain writable');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
await appendJournalLine(projectRoot, line);
|
|
348
391
|
return { event, appended: true };
|
|
349
392
|
});
|
|
350
393
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { ProgressReporter } from './progress.js';
|
|
1
2
|
import { type MigrationReport } from './types.js';
|
|
2
|
-
export declare function importV1(startPath?: string): Promise<MigrationReport>;
|
|
3
|
+
export declare function importV1(startPath?: string, onProgress?: ProgressReporter): Promise<MigrationReport>;
|
|
3
4
|
export declare function reconcileV1State(startPath?: string): Promise<void>;
|
|
4
5
|
export declare function verifyV1Import(startPath?: string): Promise<{
|
|
5
6
|
verified: boolean;
|
|
@@ -10,7 +10,7 @@ import { atomicWriteJson } from '../storage/atomic-write.js';
|
|
|
10
10
|
import { withLock } from '../storage/lock.js';
|
|
11
11
|
import { cognitionManifestPath, cognitionPolicyPath, cognitionQuarantineDirectory, cognitionV1ImportPath, cognitionV1SyncPath, contextMetadataPath, contextsRoot, locksDirectory, manifestPath, snapshotPath, snapshotsDirectory, } from '../storage/layout.js';
|
|
12
12
|
import { readJson } from '../storage/json-store.js';
|
|
13
|
-
import { appendEvent } from './journal.js';
|
|
13
|
+
import { appendEvent, readJournal } from './journal.js';
|
|
14
14
|
import { rebuildProjections } from './projections.js';
|
|
15
15
|
import { v1Fingerprint } from './v1-bridge.js';
|
|
16
16
|
import { migrationReadiness } from './readiness.js';
|
|
@@ -41,13 +41,14 @@ async function readOptional(filePath) {
|
|
|
41
41
|
throw error;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
async function sourceRecords(projectRoot) {
|
|
44
|
+
async function sourceRecords(projectRoot, onProgress) {
|
|
45
45
|
const sources = [];
|
|
46
46
|
const records = [];
|
|
47
47
|
const snapshotAnchors = new Map();
|
|
48
48
|
const v1ManifestPath = manifestPath(projectRoot);
|
|
49
49
|
sources.push({ filePath: v1ManifestPath, content: await readFile(v1ManifestPath, 'utf8') });
|
|
50
50
|
const contextIds = (await readdir(contextsRoot(projectRoot))).sort();
|
|
51
|
+
onProgress?.({ phase: 'scan-contexts', completed: 0, total: contextIds.length, unit: 'contexts' });
|
|
51
52
|
for (const contextId of contextIds) {
|
|
52
53
|
const contextFile = contextMetadataPath(projectRoot, contextId);
|
|
53
54
|
const contextContent = await readFile(contextFile, 'utf8');
|
|
@@ -73,6 +74,7 @@ async function sourceRecords(projectRoot) {
|
|
|
73
74
|
sourceHash: sha256(content),
|
|
74
75
|
});
|
|
75
76
|
}
|
|
77
|
+
onProgress?.({ phase: 'scan-contexts', completed: contextIds.indexOf(contextId) + 1, total: contextIds.length, unit: 'contexts' });
|
|
76
78
|
}
|
|
77
79
|
const noteFile = path.join(projectRoot, '.context', 'notes.json');
|
|
78
80
|
const knowledgeFile = path.join(projectRoot, '.context', 'knowledge.json');
|
|
@@ -106,7 +108,11 @@ async function writeV2Metadata(projectRoot) {
|
|
|
106
108
|
const v1 = await loadManifest(projectRoot);
|
|
107
109
|
const manifest = { schemaVersion: 2, layoutVersion: COGNITION_LAYOUT_VERSION, projectId: v1.projectId, createdAt: new Date().toISOString(), policyVersion: 1 };
|
|
108
110
|
const policy = { schemaVersion: 2, enabled: true, autoStart: true, allowHookStartup: true, inactivityTimeoutMs: 300_000, promptIntentMaxChars: 500, filesystemEventsEnabled: false, retentionMaxBytes: 64 * 1024 * 1024 };
|
|
109
|
-
|
|
111
|
+
// Interrupted imports must not replace an existing project's opt-out policy.
|
|
112
|
+
if (await readOptional(cognitionManifestPath(projectRoot)) === null)
|
|
113
|
+
await atomicWriteJson(cognitionManifestPath(projectRoot), manifest);
|
|
114
|
+
if (await readOptional(cognitionPolicyPath(projectRoot)) === null)
|
|
115
|
+
await atomicWriteJson(cognitionPolicyPath(projectRoot), policy);
|
|
110
116
|
}
|
|
111
117
|
async function quarantineReadiness(projectRoot, readiness) {
|
|
112
118
|
await mkdir(cognitionQuarantineDirectory(projectRoot), { recursive: true });
|
|
@@ -117,7 +123,7 @@ async function quarantineReadiness(projectRoot, readiness) {
|
|
|
117
123
|
issues: readiness.issues,
|
|
118
124
|
});
|
|
119
125
|
}
|
|
120
|
-
export async function importV1(startPath) {
|
|
126
|
+
export async function importV1(startPath, onProgress) {
|
|
121
127
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
122
128
|
return withLock(`${locksDirectory(projectRoot)}/cognition-migration.lock`, async () => {
|
|
123
129
|
const readiness = await migrationReadiness(projectRoot);
|
|
@@ -126,7 +132,7 @@ export async function importV1(startPath) {
|
|
|
126
132
|
await quarantineReadiness(projectRoot, report);
|
|
127
133
|
return report;
|
|
128
134
|
}
|
|
129
|
-
const { records, sourceHash } = await sourceRecords(projectRoot);
|
|
135
|
+
const { records, sourceHash } = await sourceRecords(projectRoot, onProgress);
|
|
130
136
|
const existingReceipt = await readOptional(cognitionV1ImportPath(projectRoot));
|
|
131
137
|
if (existingReceipt) {
|
|
132
138
|
const receipt = JSON.parse(existingReceipt);
|
|
@@ -138,6 +144,7 @@ export async function importV1(startPath) {
|
|
|
138
144
|
}
|
|
139
145
|
await writeV2Metadata(projectRoot);
|
|
140
146
|
const importedEventIds = [];
|
|
147
|
+
onProgress?.({ phase: 'import-records', completed: 0, total: records.length, unit: 'records' });
|
|
141
148
|
for (const record of records) {
|
|
142
149
|
const event = await appendEvent(projectRoot, {
|
|
143
150
|
type: eventType(record),
|
|
@@ -148,6 +155,7 @@ export async function importV1(startPath) {
|
|
|
148
155
|
payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
|
|
149
156
|
});
|
|
150
157
|
importedEventIds.push(event.event.eventId);
|
|
158
|
+
onProgress?.({ phase: 'import-records', completed: importedEventIds.length, total: records.length, unit: 'records' });
|
|
151
159
|
}
|
|
152
160
|
await rebuildProjections(projectRoot);
|
|
153
161
|
await atomicWriteJson(cognitionV1SyncPath(projectRoot), { schemaVersion: 2, fingerprint: await v1Fingerprint(projectRoot), updatedAt: new Date().toISOString() });
|
|
@@ -162,13 +170,49 @@ export async function reconcileV1State(startPath) {
|
|
|
162
170
|
const readiness = await migrationReadiness(projectRoot);
|
|
163
171
|
if (!readiness.eligible)
|
|
164
172
|
return;
|
|
165
|
-
const { records
|
|
173
|
+
const { records } = await sourceRecords(projectRoot);
|
|
174
|
+
const existing = await readJournal(projectRoot);
|
|
175
|
+
// Compare the latest entity state, not the whole-store hash. Read counters
|
|
176
|
+
// are not project knowledge and must not duplicate historical checkpoints.
|
|
177
|
+
const semantic = (record) => {
|
|
178
|
+
if (record.context && record.snapshot) {
|
|
179
|
+
const context = record.context;
|
|
180
|
+
return sha256(JSON.stringify({ snapshot: record.snapshot, status: context.status, title: context.title, tags: context.tags, dependencies: context.dependencies }));
|
|
181
|
+
}
|
|
182
|
+
return sha256(JSON.stringify(record));
|
|
183
|
+
};
|
|
184
|
+
const identity = (record) => {
|
|
185
|
+
if (record.context && record.snapshot) {
|
|
186
|
+
const snapshot = record.snapshot;
|
|
187
|
+
return `context:${snapshot.contextId}:${snapshot.snapshotId}`;
|
|
188
|
+
}
|
|
189
|
+
if (typeof record.noteId === 'string')
|
|
190
|
+
return `note:${record.noteId}`;
|
|
191
|
+
if (typeof record.knowledgeId === 'string')
|
|
192
|
+
return `knowledge:${record.knowledgeId}`;
|
|
193
|
+
if (typeof record.deadendId === 'string')
|
|
194
|
+
return `deadend:${record.deadendId}`;
|
|
195
|
+
return null;
|
|
196
|
+
};
|
|
197
|
+
const latest = new Map();
|
|
198
|
+
for (const event of existing) {
|
|
199
|
+
const record = event.payload.record;
|
|
200
|
+
if (record) {
|
|
201
|
+
const key = identity(record);
|
|
202
|
+
if (key)
|
|
203
|
+
latest.set(key, semantic(record));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
166
206
|
for (const record of records) {
|
|
207
|
+
const key = `${record.kind}:${record.id}`;
|
|
208
|
+
const contentHash = semantic(record.record);
|
|
209
|
+
if (latest.get(key) === contentHash)
|
|
210
|
+
continue;
|
|
167
211
|
await appendEvent(projectRoot, {
|
|
168
212
|
type: eventType(record, true),
|
|
169
213
|
occurredAt: record.occurredAt,
|
|
170
214
|
source: { kind: 'migration' },
|
|
171
|
-
idempotencyKey: `v1-live-sync:${
|
|
215
|
+
idempotencyKey: `v1-live-sync-v2:${existing.at(-1)?.hash ?? 'empty'}:${key}:${contentHash}`,
|
|
172
216
|
git: { worktreeId: null, branch: record.git.branch, ref: record.git.branch, head: record.git.head },
|
|
173
217
|
payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
|
|
174
218
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { MigrationReadiness } from './types.js';
|
|
2
|
-
|
|
2
|
+
import type { ProgressReporter } from './progress.js';
|
|
3
|
+
export declare function migrationReadiness(startPath?: string, onProgress?: ProgressReporter): Promise<MigrationReadiness>;
|
|
@@ -14,7 +14,7 @@ async function exists(filePath) {
|
|
|
14
14
|
return false;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
export async function migrationReadiness(startPath) {
|
|
17
|
+
export async function migrationReadiness(startPath, onProgress) {
|
|
18
18
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
19
19
|
const counts = { contexts: 0, snapshots: 0, notes: 0, knowledge: 0, deadends: 0 };
|
|
20
20
|
const issues = [];
|
|
@@ -40,7 +40,8 @@ export async function migrationReadiness(startPath) {
|
|
|
40
40
|
catch (error) {
|
|
41
41
|
issues.push(`v1 contexts: ${error instanceof Error ? error.message : 'unavailable'}`);
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
onProgress?.({ phase: 'inspect-contexts', completed: 0, total: contextIds.length, unit: 'contexts' });
|
|
44
|
+
for (const [index, contextId] of contextIds.entries()) {
|
|
44
45
|
try {
|
|
45
46
|
const context = await readJson(contextMetadataPath(projectRoot, contextId));
|
|
46
47
|
if (context.schemaVersion !== 1 || context.id !== contextId || context.projectRoot !== projectRoot) {
|
|
@@ -58,6 +59,7 @@ export async function migrationReadiness(startPath) {
|
|
|
58
59
|
catch (error) {
|
|
59
60
|
issues.push(`context ${contextId}: ${error instanceof Error ? error.message : 'invalid'}`);
|
|
60
61
|
}
|
|
62
|
+
onProgress?.({ phase: 'inspect-contexts', completed: index + 1, total: contextIds.length, unit: 'contexts' });
|
|
61
63
|
}
|
|
62
64
|
try {
|
|
63
65
|
counts.notes = (await listNotes(projectRoot, { limit: Number.MAX_SAFE_INTEGER, maxChars: Number.MAX_SAFE_INTEGER })).length;
|
|
@@ -249,6 +249,10 @@ export async function serveRuntime(startPath, options = {}) {
|
|
|
249
249
|
return closing;
|
|
250
250
|
};
|
|
251
251
|
const touch = () => {
|
|
252
|
+
// Draining observations during shutdown can still notify activity. Never
|
|
253
|
+
// recreate an inactivity timer after closeServer has cleared the timers.
|
|
254
|
+
if (closing)
|
|
255
|
+
return;
|
|
252
256
|
if (inactivity)
|
|
253
257
|
clearTimeout(inactivity);
|
|
254
258
|
inactivity = setTimeout(() => { void close('inactivity').catch(() => undefined); }, inactivityTimeoutMs);
|
|
@@ -2,6 +2,8 @@ import { readdir } from 'node:fs/promises';
|
|
|
2
2
|
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
3
3
|
import { readJson, isRecord } from '../storage/json-store.js';
|
|
4
4
|
import { contextsRoot, contextMetadataPath, snapshotsDirectory } from '../storage/layout.js';
|
|
5
|
+
import { journalStorage } from './journal-segments.js';
|
|
6
|
+
import { readAdmissionStats } from './admission.js';
|
|
5
7
|
import { readJournal } from './journal.js';
|
|
6
8
|
import { listDeadends, listKnowledge } from '../runtime/knowledge.js';
|
|
7
9
|
import { listNotes } from '../runtime/notes.js';
|
|
@@ -123,6 +125,8 @@ function operationStats(events) {
|
|
|
123
125
|
return byOperation;
|
|
124
126
|
}
|
|
125
127
|
export async function buildUsageReport(startPath, options = {}) {
|
|
128
|
+
if (options.baselineTokens !== undefined && (!Number.isSafeInteger(options.baselineTokens) || options.baselineTokens < 0))
|
|
129
|
+
throw new Error('baselineTokens must be a non-negative safe integer');
|
|
126
130
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
127
131
|
const window = windowOf(options);
|
|
128
132
|
let events = [];
|
|
@@ -136,6 +140,13 @@ export async function buildUsageReport(startPath, options = {}) {
|
|
|
136
140
|
journalVerified = false;
|
|
137
141
|
}
|
|
138
142
|
const scoped = events.filter((event) => inWindow(event, window));
|
|
143
|
+
const storage = await journalStorage(projectRoot, true);
|
|
144
|
+
const admission = await readAdmissionStats(projectRoot);
|
|
145
|
+
const journalBytes = storage.bytes;
|
|
146
|
+
const eventTypes = {};
|
|
147
|
+
for (const event of scoped)
|
|
148
|
+
eventTypes[event.type] = (eventTypes[event.type] ?? 0) + 1;
|
|
149
|
+
const semanticEvents = scoped.filter((event) => /^(context\.|knowledge\.|deadend\.|note\.|experience\.|evolution\.|skill\.|recipe\.|record\.used)/.test(event.type)).length;
|
|
139
150
|
const usageEvents = scoped.map((event) => ({ event, payload: usagePayload(event) })).filter((item) => item.payload !== null);
|
|
140
151
|
const knowledge = await listKnowledge(projectRoot, true);
|
|
141
152
|
const deadends = await listDeadends(projectRoot, true);
|
|
@@ -175,21 +186,21 @@ export async function buildUsageReport(startPath, options = {}) {
|
|
|
175
186
|
buckets.set(key, bucket);
|
|
176
187
|
}
|
|
177
188
|
const ctxTokens = usageEvents.reduce((sum, item) => sum + item.payload.estimatedOutputTokens, 0);
|
|
189
|
+
const inputTokens = usageEvents.reduce((sum, item) => sum + item.payload.estimatedInputTokens, 0);
|
|
190
|
+
const totalTokens = ctxTokens + inputTokens;
|
|
178
191
|
const baseline = options.baselineTokens ?? null;
|
|
179
|
-
const savings = baseline === null ? null : baseline -
|
|
192
|
+
const savings = baseline === null || usageEvents.length === 0 ? null : baseline - totalTokens;
|
|
180
193
|
const investment = metric(ctxTokens, 'estimated', 'tokens', 'chars-div-four');
|
|
181
194
|
const rawAssets = verified.length;
|
|
182
195
|
const usedCount = verified.reduce((sum, key) => sum + Math.min(used.get(key) ?? 0, 3), 0);
|
|
183
196
|
const trendEntries = [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
184
197
|
let cumulativeInvestmentTokens = 0;
|
|
185
198
|
let cumulativeOutputChars = 0;
|
|
186
|
-
let paybackPeriodStart = null;
|
|
187
199
|
const trends = trendEntries.map(([periodStart, values]) => {
|
|
188
200
|
cumulativeInvestmentTokens += values.estimatedOutputTokens;
|
|
189
201
|
cumulativeOutputChars += values.outputChars;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
paybackPeriodStart = periodStart;
|
|
202
|
+
// A window-total baseline cannot establish when savings occurred.
|
|
203
|
+
const cumulativeSavings = null;
|
|
193
204
|
return {
|
|
194
205
|
periodStart,
|
|
195
206
|
...values,
|
|
@@ -198,35 +209,39 @@ export async function buildUsageReport(startPath, options = {}) {
|
|
|
198
209
|
cumulativeEstimatedSavings: cumulativeSavings,
|
|
199
210
|
};
|
|
200
211
|
});
|
|
201
|
-
const paybackReached = paybackPeriodStart !== null;
|
|
202
|
-
const paybackPeriodDays = paybackPeriodStart === null ? null : Math.max(0, (Date.parse(paybackPeriodStart) - Date.parse(window.from)) / 86_400_000);
|
|
203
212
|
return {
|
|
204
213
|
schemaVersion: 1, generatedAt: new Date().toISOString(), projectRoot, window,
|
|
205
|
-
dataQuality: { journalVerified, journalEventCount: events.length, windowEventCount: scoped.length, usageEventCount: usageEvents.length, firstUsageAt: usageEvents[0]?.event.occurredAt ?? null, lastUsageAt: usageEvents.at(-1)?.event.occurredAt ?? null, measuredMetricCount: usageEvents.length * 7, estimatedMetricCount: usageEvents.length * 2 + (ctxTokens > 0 ? 1 : 0), unavailableMetricCount: 1 },
|
|
214
|
+
dataQuality: { journalVerified, journalBytes, journalStorage: storage, admission, eventTypes, semanticEventRatio: metric(scoped.length ? semanticEvents / scoped.length : null, 'derived', 'ratio', 'semantic lifecycle events / window events; not a correctness score'), journalEventCount: events.length, windowEventCount: scoped.length, usageEventCount: usageEvents.length, firstUsageAt: usageEvents[0]?.event.occurredAt ?? null, lastUsageAt: usageEvents.at(-1)?.event.occurredAt ?? null, measuredMetricCount: usageEvents.length * 7, estimatedMetricCount: usageEvents.length * 2 + (ctxTokens > 0 ? 1 : 0), unavailableMetricCount: 1 },
|
|
206
215
|
assets: {
|
|
207
216
|
knowledge: { total: knowledge.length, candidate: knowledge.filter((item) => item.status === 'candidate').length, verified: knowledge.filter((item) => item.status === 'verified').length, retired: knowledge.filter((item) => ['deprecated', 'rejected'].includes(item.status)).length, created: lifecycle('knowledge.proposed'), verifiedInWindow: lifecycle('knowledge.verified') },
|
|
208
217
|
deadends: { total: deadends.length, candidate: deadends.filter((item) => item.status === 'candidate').length, verified: deadends.filter((item) => item.status === 'verified').length, retired: deadends.filter((item) => ['obsolete', 'rejected'].includes(item.status)).length, created: lifecycle('deadend.proposed'), verifiedInWindow: lifecycle('deadend.verified') },
|
|
209
218
|
notes: { total: notes.length, createdInWindow: lifecycle('note.recorded') }, contexts: { total: contextData.items.length, active: contextData.items.filter((item) => ['active', 'stable'].includes(item.status)).length, snapshots: contextData.snapshots }, rawVerifiedAssetCount: rawAssets, effectiveAssetPoints: metric(rawAssets + usedCount * 0.25, 'derived', 'asset-points'), assumptions: { reuseBonusPerUse: 0.25, maxBonusUses: 3 },
|
|
210
219
|
},
|
|
211
220
|
reuse: { recordUsedInWindow: recordUsed, recordUsedTotal: events.filter((event) => event.type === 'record.used').length, usedVerifiedAssetCount: usedVerified, verifiedAssetReuseRate: metric(rawAssets === 0 ? null : usedVerified / rawAssets, 'derived', 'ratio') },
|
|
212
|
-
investment: { contextOutputTokens: investment, providerTokens: metric(null, 'unavailable', 'tokens') },
|
|
221
|
+
investment: { contextOutputTokens: investment, contextInputTokens: metric(inputTokens, 'estimated', 'tokens', 'chars-div-four'), totalContextTokens: metric(totalTokens, 'estimated', 'tokens', 'chars-div-four'), providerTokens: metric(null, 'unavailable', 'tokens') },
|
|
213
222
|
payback: {
|
|
214
223
|
baselineTokens: baseline,
|
|
215
224
|
baselineSource: options.baselineSource ?? null,
|
|
216
225
|
estimatedSavings: savings === null ? metric(null, 'unavailable', 'tokens') : metric(savings, 'estimated', 'tokens'),
|
|
217
|
-
estimatedROI:
|
|
218
|
-
status: baseline === null ? 'no_baseline' :
|
|
219
|
-
paybackAt:
|
|
220
|
-
paybackPeriodDays,
|
|
226
|
+
estimatedROI: savings === null || totalTokens === 0 ? null : savings / totalTokens,
|
|
227
|
+
status: baseline === null ? 'no_baseline' : 'unpaired_baseline',
|
|
228
|
+
paybackAt: null,
|
|
229
|
+
paybackPeriodDays: null,
|
|
221
230
|
},
|
|
222
231
|
operations: { available: usageEvents.length > 0, byOperation: operationStats(scoped), reason: usageEvents.length > 0 ? null : 'operation output telemetry is not yet recorded' },
|
|
223
232
|
trends,
|
|
224
|
-
warnings: [...(!journalVerified ? ['v2 journal unavailable; lifecycle metrics may be incomplete'] : []), 'Provider token usage is unavailable', ...(usageEvents.length === 0 ? ['Token investment is unavailable until operation output telemetry is recorded'] : [])],
|
|
233
|
+
warnings: [...(!journalVerified ? ['v2 journal unavailable; lifecycle metrics may be incomplete'] : []), 'Provider token usage is unavailable', 'chars-div-four is a rough estimate, especially for CJK; context compression is not proven task savings', ...(baseline !== null ? ['Window baseline is unpaired; no causal savings or payback date can be established'] : []), ...(usageEvents.length === 0 ? ['Token investment is unavailable until operation output telemetry is recorded'] : [])],
|
|
225
234
|
};
|
|
226
235
|
}
|
|
227
236
|
export function formatUsageReport(report) {
|
|
228
237
|
const assets = report.assets;
|
|
229
238
|
const payback = report.payback;
|
|
230
|
-
const lines = ['fluffy-context usage report', `Window: ${String(report.window.from).slice(0, 10)} to ${String(report.window.to).slice(0, 10)}`, '', 'ASSET VALUE', ` Verified knowledge ${assets.knowledge.verified}`, ` Verified deadends ${assets.deadends.verified}`, ` Used verified assets ${report.reuse.usedVerifiedAssetCount}`, ` Asset points ${assets.effectiveAssetPoints.value.toFixed(2)} derived`, '', 'TOKEN INVESTMENT', ` Estimated tokens ${report.investment.
|
|
239
|
+
const lines = ['fluffy-context usage report', `Window: ${String(report.window.from).slice(0, 10)} to ${String(report.window.to).slice(0, 10)}`, '', 'ASSET VALUE', ` Verified knowledge ${assets.knowledge.verified}`, ` Verified deadends ${assets.deadends.verified}`, ` Used verified assets ${report.reuse.usedVerifiedAssetCount}`, ` Asset points ${assets.effectiveAssetPoints.value.toFixed(2)} derived`, '', 'TOKEN INVESTMENT', ` Estimated tokens ${report.investment.totalContextTokens.value} estimated (input + output)`, ' Provider tokens n/a unavailable', '', 'RETURN', ` Baseline tokens ${payback.baselineTokens ?? 'n/a'}`, ` Estimated savings ${payback.estimatedSavings.value ?? 'n/a'}`, ` ROI ${payback.estimatedROI ?? 'n/a'}`, ` Payback ${payback.status}`, '', 'COMMAND OUTPUT TREND', ` Usage events ${report.dataQuality.usageEventCount}`, '', 'DATA QUALITY', ` Journal verified ${report.dataQuality.journalVerified ? 'yes' : 'no'}`, ` Window events ${report.dataQuality.windowEventCount}`, ` Warnings ${report.warnings.length}`];
|
|
240
|
+
lines.push(` Journal bytes ${report.dataQuality.journalBytes}`, ` Semantic event ratio ${report.dataQuality.semanticEventRatio.value ?? 'n/a'} derived`, '', 'EVENT TYPES');
|
|
241
|
+
lines.push(` Archives ${report.dataQuality.journalStorage.archives}`, ` Expanded journal bytes ${report.dataQuality.journalStorage.expandedBytes}`);
|
|
242
|
+
for (const [type, count] of Object.entries(report.dataQuality.eventTypes))
|
|
243
|
+
lines.push(` ${type}: ${count}`);
|
|
244
|
+
lines.push(` Budget-rejected observations ${Object.values(report.dataQuality.admission.dropped).reduce((sum, count) => Number(sum) + Number(count), 0)}`);
|
|
245
|
+
lines.push('', ...report.warnings.map((warning) => ` ${warning}`));
|
|
231
246
|
return `${lines.join('\n')}\n`;
|
|
232
247
|
}
|
|
@@ -197,32 +197,32 @@ function activityText(record) {
|
|
|
197
197
|
return `activity: ${changes}`;
|
|
198
198
|
}
|
|
199
199
|
function createCandidates(sources, intent, notes, knowledge, deadends, activities) {
|
|
200
|
-
if (sources.orientation.status !== 'ready')
|
|
201
|
-
return [];
|
|
202
|
-
const { context, snapshot, resumeSummary } = sources.orientation;
|
|
203
200
|
const terms = intent.normalizedTerms;
|
|
204
201
|
const candidates = [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
202
|
+
if (sources.orientation.status === 'ready') {
|
|
203
|
+
const { context, snapshot, resumeSummary } = sources.orientation;
|
|
204
|
+
const contextSource = sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'current-context', context.id, context.id, snapshot.snapshotId, context.status, null, resumeSummary.relatedFiles);
|
|
205
|
+
const summaryFields = [
|
|
206
|
+
['title', context.title, 'summary'],
|
|
207
|
+
['progressSummary', resumeSummary.progressSummary, 'summary'],
|
|
208
|
+
['lastError', resumeSummary.lastError, 'risk'],
|
|
209
|
+
];
|
|
210
|
+
for (const [field, value, section] of summaryFields) {
|
|
211
|
+
const text = textForSummary(field, value);
|
|
212
|
+
if (text)
|
|
213
|
+
candidates.push(makeCandidate(section, contextSource, { field, value }, text, terms, [field], field));
|
|
214
|
+
}
|
|
215
|
+
const listFields = [
|
|
216
|
+
['pendingTasks', resumeSummary.pendingTasks, 'pending'],
|
|
217
|
+
['completed', resumeSummary.completed, 'summary'],
|
|
218
|
+
['decisions', resumeSummary.decisions, 'decision'],
|
|
219
|
+
['risks', resumeSummary.risks, 'risk'],
|
|
220
|
+
['relatedFiles', resumeSummary.relatedFiles, 'summary'],
|
|
221
|
+
];
|
|
222
|
+
for (const [field, values, section] of listFields) {
|
|
223
|
+
for (const [index, value] of values.entries()) {
|
|
224
|
+
candidates.push(makeCandidate(section, contextSource, { field, value }, `${field}: ${value}`, terms, [field], `${field}:${index}`));
|
|
225
|
+
}
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
for (const note of notes) {
|
|
@@ -288,8 +288,8 @@ export async function compileContext(startPath, options = {}) {
|
|
|
288
288
|
const orientation = sources.orientation;
|
|
289
289
|
const maxChars = intent.budget.total;
|
|
290
290
|
const notes = orientation.status === 'ready' ? (await listNotes(sources.anchor.projectRoot, { contextId: orientation.context.id, openOnly: true, limit: options.noteLimit ?? 50, maxChars: Number.MAX_SAFE_INTEGER })).filter((note) => orientation.notes.some((selected) => selected.noteId === note.noteId)) : [];
|
|
291
|
-
const knowledge =
|
|
292
|
-
const deadends =
|
|
291
|
+
const knowledge = intent.query ? await listKnowledge(sources.anchor.projectRoot, true) : [];
|
|
292
|
+
const deadends = intent.query ? await listDeadends(sources.anchor.projectRoot, true) : [];
|
|
293
293
|
const activities = await extractActivity(sources.anchor.projectRoot, sources.events, sources.anchor, sources.workspace, options.activityLimit ?? 20);
|
|
294
294
|
const all = createCandidates(sources, intent, notes, knowledge, deadends, activities);
|
|
295
295
|
const excluded = [];
|
|
@@ -374,8 +374,9 @@ export async function compileContext(startPath, options = {}) {
|
|
|
374
374
|
};
|
|
375
375
|
const tokenEstimate = estimateTokens(manifestText(selected));
|
|
376
376
|
const selectedItemHashes = selected.map((item) => item.itemHash);
|
|
377
|
+
const status = orientation.status === 'ready' || selected.length > 0 ? 'ready' : 'no_context';
|
|
377
378
|
const manifestHash = rebuildManifestHash({
|
|
378
|
-
status
|
|
379
|
+
status,
|
|
379
380
|
projectRoot: sources.anchor.projectRoot,
|
|
380
381
|
mode: orientation.explanation.mode,
|
|
381
382
|
anchor: sources.anchor,
|
|
@@ -390,7 +391,7 @@ export async function compileContext(startPath, options = {}) {
|
|
|
390
391
|
schemaVersion: 1,
|
|
391
392
|
compilerVersion: '0.7.7',
|
|
392
393
|
rankingVersion: 'compiler-ranking-v2',
|
|
393
|
-
status
|
|
394
|
+
status,
|
|
394
395
|
projectRoot: sources.anchor.projectRoot,
|
|
395
396
|
mode: orientation.explanation.mode,
|
|
396
397
|
anchor: sources.anchor,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ContextCompileOptions, ContextScenario } from './types.js';
|
|
2
|
+
export declare function inferScenario(query: string): ContextScenario;
|
|
3
|
+
export interface InjectionOptions extends ContextCompileOptions {
|
|
4
|
+
explain?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function injectContext(startPath: string | undefined, options?: InjectionOptions, interfaceName?: 'cli' | 'hook' | 'agent' | 'mcp'): Promise<{
|
|
7
|
+
next: string | null;
|
|
8
|
+
sources?: {
|
|
9
|
+
candidateId: string;
|
|
10
|
+
itemHash: string;
|
|
11
|
+
section: import("./types.js").ContextCandidateSection;
|
|
12
|
+
provenance: import("./types.js").ContextProvenance;
|
|
13
|
+
}[] | undefined;
|
|
14
|
+
status: "ready" | "no_context";
|
|
15
|
+
scenario: ContextScenario;
|
|
16
|
+
text: string;
|
|
17
|
+
budget: {
|
|
18
|
+
maxChars: number;
|
|
19
|
+
usedChars: number;
|
|
20
|
+
};
|
|
21
|
+
manifestHash: string;
|
|
22
|
+
sourceCount: number;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { compileContext } from './compile.js';
|
|
2
|
+
import { withUsageTelemetry } from '../cognition/usage.js';
|
|
3
|
+
export function inferScenario(query) {
|
|
4
|
+
if (/error|fail|bug|异常|报错|失败|修复/i.test(query))
|
|
5
|
+
return 'debugging';
|
|
6
|
+
if (/test|verify|验证|测试/i.test(query))
|
|
7
|
+
return 'verification';
|
|
8
|
+
if (/resume|恢复|继续/i.test(query))
|
|
9
|
+
return 'resume';
|
|
10
|
+
if (/handoff|交接|总结/i.test(query))
|
|
11
|
+
return 'handoff';
|
|
12
|
+
if (/implement|add|build|实现|新增|开发/i.test(query))
|
|
13
|
+
return 'implementation';
|
|
14
|
+
return 'exploration';
|
|
15
|
+
}
|
|
16
|
+
export async function injectContext(startPath, options = {}, interfaceName = 'agent') {
|
|
17
|
+
return withUsageTelemetry(startPath, 'compile', interfaceName === 'hook' ? 'claude-hook' : interfaceName, options, async () => {
|
|
18
|
+
const scenario = options.scenario ?? inferScenario(options.query ?? '');
|
|
19
|
+
const manifest = await compileContext(startPath, { ...options, scenario, maxChars: options.maxChars ?? 2400, activityLimit: options.activityLimit ?? 0 });
|
|
20
|
+
return { status: manifest.status, scenario, text: manifest.text,
|
|
21
|
+
budget: { maxChars: manifest.budget.requested.total, usedChars: manifest.budget.usedChars },
|
|
22
|
+
manifestHash: manifest.identity.manifestHash,
|
|
23
|
+
sourceCount: manifest.selected.length,
|
|
24
|
+
...(options.explain ? { sources: manifest.selected.map((item) => ({ candidateId: item.candidateId, itemHash: item.itemHash, section: item.section, provenance: item.source.provenance })) } : {}),
|
|
25
|
+
next: manifest.truncation.truncated ? 'Use ctx compile / ctx expand for selected evidence.' : null };
|
|
26
|
+
}, interfaceName);
|
|
27
|
+
}
|