fluffy-context 0.8.0 → 1.0.0-beta.1
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 +35 -0
- package/README.md +109 -613
- package/ROADMAP-1.0.md +70 -0
- package/dist/src/agent/index.d.ts +3 -0
- package/dist/src/agent/index.js +3 -0
- package/dist/src/capture/context-filter.js +2 -0
- package/dist/src/cli/main.js +50 -10
- 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 +13 -0
- package/dist/src/cognition/journal.js +56 -19
- package/dist/src/cognition/migration-v1.js +39 -3
- 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/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 +15 -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,9 @@ 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
|
+
for (const file of files) {
|
|
262
|
+
const content = (await readSegment(projectRoot, file)).toString('utf8');
|
|
270
263
|
const lines = content.split('\n');
|
|
271
264
|
for (let index = 0; index < lines.length; index += 1) {
|
|
272
265
|
const line = lines[index];
|
|
@@ -277,7 +270,7 @@ export async function readJournal(startPath, options = {}) {
|
|
|
277
270
|
event = JSON.parse(line);
|
|
278
271
|
}
|
|
279
272
|
catch (error) {
|
|
280
|
-
if (options.allowPartialTail !== false && index === lines.length - 1 && !content.endsWith('\n'))
|
|
273
|
+
if (options.allowPartialTail !== false && file === files.at(-1) && !file.endsWith('.gz') && index === lines.length - 1 && !content.endsWith('\n'))
|
|
281
274
|
break;
|
|
282
275
|
throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
|
|
283
276
|
}
|
|
@@ -296,6 +289,36 @@ export async function readJournal(startPath, options = {}) {
|
|
|
296
289
|
export async function verifyJournal(startPath) {
|
|
297
290
|
return readJournal(startPath, { allowPartialTail: false });
|
|
298
291
|
}
|
|
292
|
+
export async function compactJournal(startPath, options = {}) {
|
|
293
|
+
const root = await resolveProjectRoot(startPath);
|
|
294
|
+
const minBytes = options.minBytes ?? 1;
|
|
295
|
+
if (!Number.isSafeInteger(minBytes) || minBytes < 0)
|
|
296
|
+
throw new Error('minBytes must be a non-negative integer');
|
|
297
|
+
const operation = async () => {
|
|
298
|
+
const events = await verifyJournal(root);
|
|
299
|
+
const before = await journalStorage(root);
|
|
300
|
+
const eligible = [];
|
|
301
|
+
for (const file of await journalFiles(root)) {
|
|
302
|
+
if (!file.endsWith('.gz') && (await readSegment(root, file)).length >= minBytes)
|
|
303
|
+
eligible.push(file);
|
|
304
|
+
}
|
|
305
|
+
if (options.apply) {
|
|
306
|
+
for (const file of eligible)
|
|
307
|
+
await archiveSegment(root, file);
|
|
308
|
+
// Complete deletion after an interrupted publication, but only after validation.
|
|
309
|
+
for (const file of await journalFiles(root))
|
|
310
|
+
if (file.endsWith('.gz'))
|
|
311
|
+
await archiveSegment(root, file);
|
|
312
|
+
const replay = await verifyJournal(root);
|
|
313
|
+
if (replay.length !== events.length || replay.at(-1)?.hash !== events.at(-1)?.hash)
|
|
314
|
+
throw new Error('journal replay changed during compaction');
|
|
315
|
+
}
|
|
316
|
+
const after = options.apply ? await journalStorage(root) : before;
|
|
317
|
+
return { applied: options.apply === true, eligible, events: events.length, headHash: events.at(-1)?.hash ?? null,
|
|
318
|
+
bytesBefore: before.bytes, bytesAfter: after.bytes, savedBytes: before.bytes - after.bytes, archives: after.archives };
|
|
319
|
+
};
|
|
320
|
+
return options.apply ? withLock(`${locksDirectory(root)}/cognition-journal.lock`, operation) : operation();
|
|
321
|
+
}
|
|
299
322
|
export async function appendEvent(startPath, input) {
|
|
300
323
|
validatePayloadBoundary(input);
|
|
301
324
|
const projectRoot = await resolveProjectRoot(startPath);
|
|
@@ -343,8 +366,22 @@ export async function appendEvent(startPath, input) {
|
|
|
343
366
|
previousHash: existing.at(-1)?.hash ?? null,
|
|
344
367
|
};
|
|
345
368
|
const event = { ...eventWithoutHash, hash: eventHash(eventWithoutHash) };
|
|
346
|
-
|
|
347
|
-
|
|
369
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
370
|
+
if (['workspace.paths.changed', 'host.prompt.submitted', 'usage.operation.completed'].includes(input.type)) {
|
|
371
|
+
let limit;
|
|
372
|
+
try {
|
|
373
|
+
limit = (await loadRuntimePolicy(projectRoot)).retentionMaxBytes;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error.code !== 'ENOENT')
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
if (limit !== undefined && (await journalStorage(projectRoot)).bytes + Buffer.byteLength(line) > limit) {
|
|
380
|
+
await recordAdmissionDrop(projectRoot, input.type);
|
|
381
|
+
throw new Error('observation budget exceeded; semantic records remain writable');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await appendJournalLine(projectRoot, line);
|
|
348
385
|
return { event, appended: true };
|
|
349
386
|
});
|
|
350
387
|
}
|
|
@@ -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';
|
|
@@ -162,13 +162,49 @@ export async function reconcileV1State(startPath) {
|
|
|
162
162
|
const readiness = await migrationReadiness(projectRoot);
|
|
163
163
|
if (!readiness.eligible)
|
|
164
164
|
return;
|
|
165
|
-
const { records
|
|
165
|
+
const { records } = await sourceRecords(projectRoot);
|
|
166
|
+
const existing = await readJournal(projectRoot);
|
|
167
|
+
// Compare the latest entity state, not the whole-store hash. Read counters
|
|
168
|
+
// are not project knowledge and must not duplicate historical checkpoints.
|
|
169
|
+
const semantic = (record) => {
|
|
170
|
+
if (record.context && record.snapshot) {
|
|
171
|
+
const context = record.context;
|
|
172
|
+
return sha256(JSON.stringify({ snapshot: record.snapshot, status: context.status, title: context.title, tags: context.tags, dependencies: context.dependencies }));
|
|
173
|
+
}
|
|
174
|
+
return sha256(JSON.stringify(record));
|
|
175
|
+
};
|
|
176
|
+
const identity = (record) => {
|
|
177
|
+
if (record.context && record.snapshot) {
|
|
178
|
+
const snapshot = record.snapshot;
|
|
179
|
+
return `context:${snapshot.contextId}:${snapshot.snapshotId}`;
|
|
180
|
+
}
|
|
181
|
+
if (typeof record.noteId === 'string')
|
|
182
|
+
return `note:${record.noteId}`;
|
|
183
|
+
if (typeof record.knowledgeId === 'string')
|
|
184
|
+
return `knowledge:${record.knowledgeId}`;
|
|
185
|
+
if (typeof record.deadendId === 'string')
|
|
186
|
+
return `deadend:${record.deadendId}`;
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
const latest = new Map();
|
|
190
|
+
for (const event of existing) {
|
|
191
|
+
const record = event.payload.record;
|
|
192
|
+
if (record) {
|
|
193
|
+
const key = identity(record);
|
|
194
|
+
if (key)
|
|
195
|
+
latest.set(key, semantic(record));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
166
198
|
for (const record of records) {
|
|
199
|
+
const key = `${record.kind}:${record.id}`;
|
|
200
|
+
const contentHash = semantic(record.record);
|
|
201
|
+
if (latest.get(key) === contentHash)
|
|
202
|
+
continue;
|
|
167
203
|
await appendEvent(projectRoot, {
|
|
168
204
|
type: eventType(record, true),
|
|
169
205
|
occurredAt: record.occurredAt,
|
|
170
206
|
source: { kind: 'migration' },
|
|
171
|
-
idempotencyKey: `v1-live-sync:${
|
|
207
|
+
idempotencyKey: `v1-live-sync-v2:${existing.at(-1)?.hash ?? 'empty'}:${key}:${contentHash}`,
|
|
172
208
|
git: { worktreeId: null, branch: record.git.branch, ref: record.git.branch, head: record.git.head },
|
|
173
209
|
payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
|
|
174
210
|
});
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { contextOrient } from '../agent/api.js';
|
|
2
1
|
import { appendEvent } from '../cognition/journal.js';
|
|
3
2
|
import { v2Enabled } from '../cognition/orient.js';
|
|
4
3
|
import { ensureRuntimeForSession, touchRuntime } from '../cognition/runtime.js';
|
|
5
4
|
import { loadRuntimePolicy } from '../cognition/policy.js';
|
|
6
5
|
import { readGitWorktreeState } from '../git/git-adapter.js';
|
|
7
6
|
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
7
|
+
import { injectContext } from '../compiler/inject.js';
|
|
8
8
|
const HOOK_RESPONSE = '{}\n';
|
|
9
9
|
function text(value) {
|
|
10
10
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -13,50 +13,6 @@ function compact(value, maxChars) {
|
|
|
13
13
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
14
14
|
return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars - 1)}…`;
|
|
15
15
|
}
|
|
16
|
-
function lines(label, values, limit) {
|
|
17
|
-
return values.slice(0, limit).map((value) => `- ${label}: ${compact(value, 240)}`);
|
|
18
|
-
}
|
|
19
|
-
function readyContext(result) {
|
|
20
|
-
const content = [
|
|
21
|
-
`当前 Context:${compact(result.context.title, 160)}`,
|
|
22
|
-
`进度:${compact(result.resumeSummary.progressSummary, 360)}`,
|
|
23
|
-
...lines('待办', result.resumeSummary.pendingTasks, 4),
|
|
24
|
-
...lines('决策', result.resumeSummary.decisions, 3),
|
|
25
|
-
...lines('风险', result.resumeSummary.risks, 3),
|
|
26
|
-
...result.knowledge.hits.slice(0, 5).map((hit) => `- 已验证知识:${compact(hit.knowledge.statement, 240)}`),
|
|
27
|
-
...result.deadends.hits.slice(0, 3).map((hit) => `- 已验证死路(避免):${compact(hit.deadend.attempt, 180)};${compact(hit.deadend.reason, 180)}`),
|
|
28
|
-
...result.notes.slice(0, 8).map((note) => `- 开放 Note:${compact(note.message, 220)}`),
|
|
29
|
-
];
|
|
30
|
-
if (result.truncated)
|
|
31
|
-
content.push('- 上下文已按预算截断;需要时使用 context_orient 或 ctx orient 进一步查询。');
|
|
32
|
-
return content;
|
|
33
|
-
}
|
|
34
|
-
function sessionContext(result) {
|
|
35
|
-
const phases = [
|
|
36
|
-
'开发阶段:Orient → Plan → Implement → Verify → Handoff。',
|
|
37
|
-
'先基于以下上下文明确计划;实现中记录重要观察/决策/阻塞为 ctx note;验证后显式 ctx checkpoint,总结完成、待办、决策和风险。不要自动保存 Snapshot。',
|
|
38
|
-
];
|
|
39
|
-
if (result.status === 'no_context') {
|
|
40
|
-
phases.push('当前没有可恢复的 Context。开始有意义的工作后,使用 ctx checkpoint 创建一个。');
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
phases.push(...readyContext(result));
|
|
44
|
-
}
|
|
45
|
-
return phases.join('\n');
|
|
46
|
-
}
|
|
47
|
-
function promptContext(result) {
|
|
48
|
-
const phases = [
|
|
49
|
-
'开发阶段:Implement → Verify → Handoff。',
|
|
50
|
-
'实现时用 ctx note 记录重要观察、决策或阻塞;完成后运行适用的验证。阶段结束时显式 ctx checkpoint(可 --absorb-notes),不要由 Hook 自动保存。',
|
|
51
|
-
];
|
|
52
|
-
if (result.status === 'no_context') {
|
|
53
|
-
phases.push('当前没有可恢复的 Context;如果这项工作会继续,先建立 checkpoint。');
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
phases.push(...readyContext(result));
|
|
57
|
-
}
|
|
58
|
-
return phases.join('\n');
|
|
59
|
-
}
|
|
60
16
|
function stableId(input, name) {
|
|
61
17
|
return name === 'session' ? text(input.session_id) ?? text(input.sessionId) : text(input.event_id) ?? text(input.eventId);
|
|
62
18
|
}
|
|
@@ -111,17 +67,22 @@ export async function runClaudeCodeHook(event, raw) {
|
|
|
111
67
|
await capture(event, input).catch(() => undefined);
|
|
112
68
|
await touchRuntime(text(input.cwd)).catch(() => false);
|
|
113
69
|
const cwd = text(input.cwd);
|
|
114
|
-
const result =
|
|
115
|
-
|
|
116
|
-
:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
70
|
+
const result = await injectContext(cwd, {
|
|
71
|
+
query: compact(text(input.prompt) ?? '', 500) || undefined,
|
|
72
|
+
scenario: event === 'session-start' ? 'resume' : undefined,
|
|
73
|
+
trigger: event === 'session-start' ? 'session-start' : 'prompt',
|
|
74
|
+
adapter: 'claude-code',
|
|
75
|
+
maxChars: event === 'session-start' ? 1200 : 1800,
|
|
76
|
+
knowledgeLimit: 5,
|
|
77
|
+
deadendLimit: 3,
|
|
78
|
+
noteLimit: 8,
|
|
79
|
+
}, 'hook');
|
|
80
|
+
const guidance = event === 'session-start' ? 'Orient → Plan → Implement → Verify → Handoff' : 'Implement → Verify → Handoff';
|
|
81
|
+
const additionalContext = `${guidance}\n将项目上下文作为证据;重要发现用 ctx note,阶段结束用 ctx checkpoint。\n${result.status === 'no_context' ? '当前没有可恢复的 Context。\n' : ''}${result.text}`;
|
|
82
|
+
return `${JSON.stringify({ hookSpecificOutput: {
|
|
83
|
+
hookEventName: event === 'session-start' ? 'SessionStart' : 'UserPromptSubmit',
|
|
84
|
+
additionalContext,
|
|
85
|
+
} })}\n`;
|
|
125
86
|
}
|
|
126
87
|
catch {
|
|
127
88
|
return HOOK_RESPONSE;
|