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.
Files changed (41) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +119 -604
  3. package/ROADMAP-1.0.md +74 -0
  4. package/dist/src/agent/index.d.ts +4 -0
  5. package/dist/src/agent/index.js +4 -0
  6. package/dist/src/capture/context-filter.js +2 -0
  7. package/dist/src/cli/main.js +71 -10
  8. package/dist/src/cli/progress.d.ts +2 -0
  9. package/dist/src/cli/progress.js +21 -0
  10. package/dist/src/cognition/admission.d.ts +8 -0
  11. package/dist/src/cognition/admission.js +24 -0
  12. package/dist/src/cognition/import-memory.d.ts +18 -0
  13. package/dist/src/cognition/import-memory.js +124 -0
  14. package/dist/src/cognition/journal-segments.d.ts +11 -0
  15. package/dist/src/cognition/journal-segments.js +129 -0
  16. package/dist/src/cognition/journal.d.ts +16 -0
  17. package/dist/src/cognition/journal.js +62 -19
  18. package/dist/src/cognition/migration-v1.d.ts +2 -1
  19. package/dist/src/cognition/migration-v1.js +51 -7
  20. package/dist/src/cognition/progress.d.ts +7 -0
  21. package/dist/src/cognition/progress.js +1 -0
  22. package/dist/src/cognition/readiness.d.ts +2 -1
  23. package/dist/src/cognition/readiness.js +4 -2
  24. package/dist/src/cognition/runtime.js +4 -0
  25. package/dist/src/cognition/usage-report.js +30 -15
  26. package/dist/src/compiler/compile.js +29 -28
  27. package/dist/src/compiler/inject.d.ts +23 -0
  28. package/dist/src/compiler/inject.js +27 -0
  29. package/dist/src/hooks/claude-code.js +17 -56
  30. package/dist/src/integrations/claude-code.js +30 -8
  31. package/dist/src/mcp/server.js +26 -0
  32. package/dist/src/runtime/knowledge.js +57 -8
  33. package/dist/src/runtime/setup.d.ts +27 -0
  34. package/dist/src/runtime/setup.js +85 -0
  35. package/dist/src/runtime/upgrade.d.ts +63 -0
  36. package/dist/src/runtime/upgrade.js +175 -0
  37. package/dist/src/storage/lock.js +19 -2
  38. package/dist/src/version.d.ts +1 -1
  39. package/dist/src/version.js +1 -1
  40. package/package.json +4 -1
  41. package/skills/fluffy-context/SKILL.md +17 -5
@@ -1,10 +1,13 @@
1
1
  import crypto from 'node:crypto';
2
- import { appendFile, mkdir, readFile, readdir, truncate } from 'node:fs/promises';
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
- for (const file of await journalFiles(projectRoot)) {
269
- const content = await readFile(path.join(cognitionJournalDirectory(projectRoot), file), 'utf8');
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
- await mkdir(cognitionJournalDirectory(projectRoot), { recursive: true });
347
- await appendFile(path.join(cognitionJournalDirectory(projectRoot), segmentName), `${JSON.stringify(event)}\n`, 'utf8');
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
- await Promise.all([atomicWriteJson(cognitionManifestPath(projectRoot), manifest), atomicWriteJson(cognitionPolicyPath(projectRoot), policy)]);
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, sourceHash } = await sourceRecords(projectRoot);
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:${sourceHash}:${record.kind}:${record.id}`,
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,7 @@
1
+ export interface ProgressUpdate {
2
+ phase: string;
3
+ completed: number;
4
+ total: number | null;
5
+ unit?: string;
6
+ }
7
+ export type ProgressReporter = (update: ProgressUpdate) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,3 @@
1
1
  import type { MigrationReadiness } from './types.js';
2
- export declare function migrationReadiness(startPath?: string): Promise<MigrationReadiness>;
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
- for (const contextId of contextIds) {
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 - ctxTokens;
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
- const cumulativeSavings = baseline === null ? null : baseline - cumulativeInvestmentTokens;
191
- if (paybackPeriodStart === null && cumulativeSavings !== null && cumulativeSavings >= cumulativeInvestmentTokens)
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: baseline === null || ctxTokens === 0 ? null : savings / ctxTokens,
218
- status: baseline === null ? 'no_baseline' : paybackReached ? 'reached' : 'not_reached',
219
- paybackAt: paybackPeriodStart,
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.contextOutputTokens.value} estimated`, ' 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}`];
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
- const contextSource = sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'current-context', context.id, context.id, snapshot.snapshotId, context.status, null, resumeSummary.relatedFiles);
206
- const summaryFields = [
207
- ['title', context.title, 'summary'],
208
- ['progressSummary', resumeSummary.progressSummary, 'summary'],
209
- ['lastError', resumeSummary.lastError, 'risk'],
210
- ];
211
- for (const [field, value, section] of summaryFields) {
212
- const text = textForSummary(field, value);
213
- if (text)
214
- candidates.push(makeCandidate(section, contextSource, { field, value }, text, terms, [field], field));
215
- }
216
- const listFields = [
217
- ['pendingTasks', resumeSummary.pendingTasks, 'pending'],
218
- ['completed', resumeSummary.completed, 'summary'],
219
- ['decisions', resumeSummary.decisions, 'decision'],
220
- ['risks', resumeSummary.risks, 'risk'],
221
- ['relatedFiles', resumeSummary.relatedFiles, 'summary'],
222
- ];
223
- for (const [field, values, section] of listFields) {
224
- for (const [index, value] of values.entries()) {
225
- candidates.push(makeCandidate(section, contextSource, { field, value }, `${field}: ${value}`, terms, [field], `${field}:${index}`));
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 = orientation.status === 'ready' && intent.query ? await listKnowledge(sources.anchor.projectRoot, true) : [];
292
- const deadends = orientation.status === 'ready' && intent.query ? await listDeadends(sources.anchor.projectRoot, true) : [];
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: orientation.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: orientation.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
+ }