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.
@@ -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,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
- 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
+ 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
- await mkdir(cognitionJournalDirectory(projectRoot), { recursive: true });
347
- await appendFile(path.join(cognitionJournalDirectory(projectRoot), segmentName), `${JSON.stringify(event)}\n`, 'utf8');
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, sourceHash } = await sourceRecords(projectRoot);
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:${sourceHash}:${record.kind}:${record.id}`,
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 - 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
+ }
@@ -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 = event === 'session-start'
115
- ? await contextOrient(cwd, { maxChars: 1200, noteLimit: 8 })
116
- : await contextOrient(cwd, {
117
- query: compact(text(input.prompt) ?? '', 500) || undefined,
118
- maxChars: 1800,
119
- knowledgeLimit: 5,
120
- deadendLimit: 3,
121
- noteLimit: 8,
122
- });
123
- const additionalContext = event === 'session-start' ? sessionContext(result) : promptContext(result);
124
- return `${JSON.stringify({ additionalContext })}\n`;
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;