fluffy-context 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  本地优先、Agent 无关的 Context 与 Knowledge Runtime,帮助 AI 编程 Agent 保存任务状态、恢复工作上下文、复用已验证共识,并生成有界且可追溯的上下文输入。
4
4
 
5
- **当前版本:0.7.6**
5
+ **当前版本:0.7.7**
6
6
 
7
7
  ```text
8
8
  checkpoint → orient → compile → expand → feedback → Agent continues
@@ -21,7 +21,7 @@ checkpoint → orient → compile → expand → feedback → Agent continues
21
21
  - `ctx compile`:生成带预算、来源和排除原因的 provider-neutral Context Manifest。
22
22
  - `ctx learn` / `ctx deadend`:记录可复用结论和失败方案,之后显式验证再用于普通检索。
23
23
 
24
- ## 0.7.6 当前能力与边界
24
+ ## 0.7.7 当前能力与边界
25
25
 
26
26
  | 能力 | 当前支持 |
27
27
  | --- | --- |
@@ -32,16 +32,17 @@ checkpoint → orient → compile → expand → feedback → Agent continues
32
32
  | 文件活动 | 可选记录经过过滤的文件 metadata,并编译为 `activity` section |
33
33
  | Runtime | 本地 v2 Runtime、Git 观察、可选文件观察、自动唤醒和 fail-open 集成 |
34
34
  | 治理 | Knowledge/Deadend 的 candidate、verify、deprecate/obsolete、reject |
35
- | 集成 | Claude Code Hook、Claude Code 配置集成、MCP `context_orient`/`context_expand`、TypeScript Agent API |
35
+ | 集成 | Claude Code Hook、Claude Code 配置集成、MCP `context_orient`/`context_expand`/`context_usage_report`、TypeScript Agent API |
36
+ | 观测 | `ctx usage report`:只读统计资产、显式复用、操作趋势和 estimated token 投入 |
36
37
 
37
- 以下能力**不属于 0.7.6**:
38
+ 以下能力**不属于 0.7.7**:
38
39
 
39
- - 没有 `ctx clean` 或 `ctx report`。
40
+ - 没有 `ctx clean`;`ctx usage report` 是当前支持的只读观测命令。
40
41
  - 不自动创建 checkpoint、Note、Knowledge 或 Deadend。
41
42
  - 不自动把 Candidate 提升为 Verified。
42
43
  - 不提供模型总结、Embedding/向量检索、远程同步、多人协作或 Context merge。
43
44
 
44
- 0.7.6 已支持 `context_expand`,用于按需取得已选候选的 `structured` 或 `evidence` 层;它是无状态、只读操作,不读取项目文件内容,也不自动记录 feedback。
45
+ 0.7.7 已支持 `context_expand`,用于按需取得已选候选的 `structured` 或 `evidence` 层;它是无状态、只读操作,不读取项目文件内容,也不自动记录 feedback。
45
46
 
46
47
  ## 快速开始
47
48
 
@@ -180,7 +181,7 @@ Manifest 主要包含:
180
181
  - `identity`:Manifest hash、选中 item hash 和 estimated token;
181
182
  - `text`:最终有界文本。
182
183
 
183
- 0.7.6 的候选项提供:
184
+ 0.7.7 的候选项提供:
184
185
 
185
186
  ```text
186
187
  level: signal | summary | structured | evidence
@@ -212,6 +213,23 @@ Compiler 具有以下边界:
212
213
  - 普通查询默认只纳入适用且已验证的 Knowledge 和 Deadend;
213
214
  - `context_expand` 只读取已选候选的安全 metadata,不读取任意项目文件内容。
214
215
 
216
+ ### `ctx usage report`
217
+
218
+ `usage report` 适合每周或每月复盘,不需要为每条 prompt 调用。它是只读的,基于 v1 文件和 v2 journal 派生统计,不创建事件、不修改 Context、Snapshot、Note 或 projection:
219
+
220
+ ```bash
221
+ ctx usage report --days 30
222
+ ctx usage report --month 2026-08 --granularity week
223
+ ctx usage report --since 2026-08-01T00:00:00.000Z \
224
+ --until 2026-09-01T00:00:00.000Z \
225
+ --baseline-tokens 12000 --baseline-source manual
226
+ ctx usage report --format ascii
227
+ ```
228
+
229
+ 默认输出稳定 JSON;`--format ascii` 仅输出终端仪表盘。报告展示资产总量和窗口增量、verified Knowledge/Deadend、显式 `record.used` reuse、orient/compile/expand/resume 操作统计、趋势以及上下文输出投入。
230
+
231
+ 报告中的 `chars / 4` 是 `estimated` token,不是真实 Provider token;Provider token 和计费金额当前为 `unavailable`。没有显式 `--baseline-tokens` 时,不会虚构 estimated savings、ROI 或 payback;查询命中、候选选择和编译纳入也不等于 reuse,只有显式 `record.used` 事件才计入复用。报告不替代 `ctx doctor`、`ctx journal verify` 或 Knowledge/Deadend 的人工治理。
232
+
215
233
  ## Knowledge、Deadend 和 Note
216
234
 
217
235
  ### Knowledge:记录可复用认知
@@ -423,7 +441,7 @@ ctx integrate claude install --apply
423
441
  ctx agent serve
424
442
  ```
425
443
 
426
- 0.7.6 当前暴露以下工具:
444
+ 0.7.7 当前暴露以下工具:
427
445
 
428
446
  ```text
429
447
  context_orient 只读、有界地获取任务上下文
@@ -609,7 +627,7 @@ npm pack --dry-run --json
609
627
 
610
628
  ## 未来方向
611
629
 
612
- 以下方向属于后续版本,不是 0.7.6 的当前能力:
630
+ 以下方向属于后续版本,不是 0.7.7 的当前能力:
613
631
 
614
632
  | 方向 | 目标 |
615
633
  | --- | --- |
@@ -1,12 +1,16 @@
1
+ import { type UsageInterface } from '../cognition/usage.js';
2
+ import { type UsageReport, type UsageReportOptions } from '../cognition/usage-report.js';
1
3
  import type { AgentLoadOptions, AgentLoadResult, AgentSaveResult, CheckpointInput, ContextSearchOptions, ContextSearchResult } from '../runtime/types.js';
2
4
  import type { ContextOrientOptions, ContextOrientResult, ContextUseInput } from './types.js';
3
- import type { ContextExpandInput, ContextExpandResult } from '../compiler/types.js';
5
+ import type { ContextExpandInput, ContextExpandResult, ContextCompileOptions, ContextManifest } from '../compiler/types.js';
4
6
  export declare function saveContext(startPath: string | undefined, input: CheckpointInput, options?: {
5
7
  minSaveIntervalMs?: number;
6
8
  }): Promise<AgentSaveResult>;
7
- export declare function contextExpand(startPath: string | undefined, input: ContextExpandInput): Promise<ContextExpandResult>;
8
- export declare function loadContext(startPath: string | undefined, options?: AgentLoadOptions): Promise<AgentLoadResult>;
9
- export declare function contextOrient(startPath: string | undefined, options?: ContextOrientOptions): Promise<ContextOrientResult>;
9
+ export declare function contextExpand(startPath: string | undefined, input: ContextExpandInput, interfaceName?: UsageInterface): Promise<ContextExpandResult>;
10
+ export declare function contextCompile(startPath: string | undefined, options?: ContextCompileOptions, interfaceName?: UsageInterface): Promise<ContextManifest>;
11
+ export declare function loadContext(startPath: string | undefined, options?: AgentLoadOptions, interfaceName?: UsageInterface): Promise<AgentLoadResult>;
12
+ export declare function contextOrient(startPath: string | undefined, options?: ContextOrientOptions, interfaceName?: UsageInterface | null): Promise<ContextOrientResult>;
13
+ export declare function contextUsageReport(startPath: string | undefined, options?: UsageReportOptions): Promise<UsageReport>;
10
14
  export declare function recordContextUse(startPath: string | undefined, input: ContextUseInput): Promise<{
11
15
  recorded: boolean;
12
16
  eventId: string;
@@ -9,6 +9,9 @@ import { discoverV2Deadends, discoverV2Knowledge } from '../cognition/retrieval.
9
9
  import { discoverDeadends, discoverKnowledge, listDeadends } from '../runtime/knowledge.js';
10
10
  import { listNotes } from '../runtime/notes.js';
11
11
  import { expandContext } from '../compiler/expand.js';
12
+ import { compileContext } from '../compiler/compile.js';
13
+ import { withUsageTelemetry } from '../cognition/usage.js';
14
+ import { buildUsageReport } from '../cognition/usage-report.js';
12
15
  function normalized(value) {
13
16
  return value.trim().toLocaleLowerCase();
14
17
  }
@@ -139,151 +142,163 @@ export async function saveContext(startPath, input, options = {}) {
139
142
  const projectRoot = await resolveProjectRoot(startPath);
140
143
  return { projectRoot, result: await checkpoint(projectRoot, input, options) };
141
144
  }
142
- export async function contextExpand(startPath, input) {
143
- const projectRoot = await resolveProjectRoot(startPath);
144
- return expandContext(projectRoot, input);
145
+ export async function contextExpand(startPath, input, interfaceName = 'agent') {
146
+ return withUsageTelemetry(startPath, 'expand', 'agent', input, () => resolveProjectRoot(startPath).then((projectRoot) => expandContext(projectRoot, input)), interfaceName);
145
147
  }
146
- export async function loadContext(startPath, options = {}) {
147
- const projectRoot = await resolveProjectRoot(startPath);
148
- const result = await resume(projectRoot, options.contextId, options.maxChars ?? 4000);
149
- const knowledge = options.query === undefined ? null : await discoverKnowledge(projectRoot, options.query, { scope: options.scope, maxChars: options.maxChars ?? 4000 });
150
- const verifiedDeadends = await listDeadends(projectRoot);
151
- return {
152
- projectRoot,
153
- context: result.context,
154
- snapshot: result.snapshot,
155
- resumeSummary: result.resumeSummary,
156
- ...(options.includeDetails ? { details: result.details } : {}),
157
- knowledge,
158
- verifiedDeadendIds: verifiedDeadends.map((item) => item.deadendId),
159
- };
148
+ export async function contextCompile(startPath, options = {}, interfaceName = 'agent') {
149
+ return withUsageTelemetry(startPath, 'compile', 'agent', options, () => compileContext(startPath, options), interfaceName);
160
150
  }
161
- export async function contextOrient(startPath, options = {}) {
162
- const projectRoot = await resolveProjectRoot(startPath);
163
- const query = options.query === undefined ? null : options.query.trim();
164
- if (query === '')
165
- throw new Error('context orient query must not be empty');
166
- let loaded;
167
- try {
168
- const v2 = await selectV2Orientation(projectRoot, options.contextId);
169
- loaded = v2
170
- ? await resumeSnapshot(projectRoot, v2.contextId, v2.snapshotId, options.maxChars ?? 4000)
171
- : await resume(projectRoot, options.contextId, options.maxChars ?? 4000, { touchLastUsedAt: false });
172
- }
173
- catch (error) {
174
- if (error instanceof Error && error.message === 'no active context found') {
175
- return {
176
- status: 'no_context',
177
- projectRoot,
178
- query,
179
- context: null,
180
- snapshot: null,
181
- resumeSummary: null,
182
- knowledge: emptyKnowledge(query ?? ''),
183
- deadends: emptyDeadends(query ?? ''),
184
- notes: [],
185
- truncated: false,
186
- explanation: {
187
- mode: 'v1-fallback',
188
- selection: 'v1',
189
- retrieval: { knowledge: {}, deadends: {} },
190
- truncation: { summary: false, knowledge: false, deadends: false, notes: false },
191
- },
192
- };
151
+ export async function loadContext(startPath, options = {}, interfaceName = 'agent') {
152
+ return withUsageTelemetry(startPath, 'resume', 'agent', options, async () => {
153
+ const projectRoot = await resolveProjectRoot(startPath);
154
+ const result = await resume(projectRoot, options.contextId, options.maxChars ?? 4000);
155
+ const knowledge = options.query === undefined ? null : await discoverKnowledge(projectRoot, options.query, { scope: options.scope, maxChars: options.maxChars ?? 4000 });
156
+ const verifiedDeadends = await listDeadends(projectRoot);
157
+ return {
158
+ projectRoot,
159
+ context: result.context,
160
+ snapshot: result.snapshot,
161
+ resumeSummary: result.resumeSummary,
162
+ ...(options.includeDetails ? { details: result.details } : {}),
163
+ knowledge,
164
+ verifiedDeadendIds: verifiedDeadends.map((item) => item.deadendId),
165
+ };
166
+ }, interfaceName);
167
+ }
168
+ export async function contextOrient(startPath, options = {}, interfaceName = 'agent') {
169
+ const action = async () => {
170
+ const projectRoot = await resolveProjectRoot(startPath);
171
+ const query = options.query === undefined ? null : options.query.trim();
172
+ if (query === '')
173
+ throw new Error('context orient query must not be empty');
174
+ let loaded;
175
+ try {
176
+ const v2 = await selectV2Orientation(projectRoot, options.contextId);
177
+ loaded = v2
178
+ ? await resumeSnapshot(projectRoot, v2.contextId, v2.snapshotId, options.maxChars ?? 4000)
179
+ : await resume(projectRoot, options.contextId, options.maxChars ?? 4000, { touchLastUsedAt: false });
193
180
  }
194
- throw error;
195
- }
196
- const v2 = await selectV2Orientation(projectRoot, options.contextId);
197
- const notes = (await listNotes(projectRoot, {
198
- contextId: loaded.context.id,
199
- openOnly: true,
200
- limit: v2 ? Number.MAX_SAFE_INTEGER : options.noteLimit ?? 20,
201
- maxChars: options.maxChars ?? 4000,
202
- })).filter((note) => !v2 || v2.noteIds.has(note.noteId)).slice(0, options.noteLimit ?? 20);
203
- const v2Knowledge = query === null || !v2 ? null : discoverV2Knowledge(v2.retrieval, query, v2.currentGit, {
204
- scope: options.scope,
205
- limit: options.knowledgeLimit ?? 10,
206
- maxChars: options.maxChars ?? 4000,
207
- });
208
- const v2Deadends = query === null || !v2 ? null : discoverV2Deadends(v2.retrieval, query, v2.currentGit, {
209
- scope: options.scope,
210
- limit: options.deadendLimit ?? 10,
211
- maxChars: options.maxChars ?? 4000,
212
- });
213
- const applicableKnowledge = query === null
214
- ? emptyKnowledge('')
215
- : v2Knowledge?.result ?? await discoverKnowledge(projectRoot, query, {
181
+ catch (error) {
182
+ if (error instanceof Error && error.message === 'no active context found') {
183
+ return {
184
+ status: 'no_context',
185
+ projectRoot,
186
+ query,
187
+ context: null,
188
+ snapshot: null,
189
+ resumeSummary: null,
190
+ knowledge: emptyKnowledge(query ?? ''),
191
+ deadends: emptyDeadends(query ?? ''),
192
+ notes: [],
193
+ truncated: false,
194
+ explanation: {
195
+ mode: 'v1-fallback',
196
+ selection: 'v1',
197
+ retrieval: { knowledge: {}, deadends: {} },
198
+ truncation: { summary: false, knowledge: false, deadends: false, notes: false },
199
+ },
200
+ };
201
+ }
202
+ throw error;
203
+ }
204
+ const v2 = await selectV2Orientation(projectRoot, options.contextId);
205
+ const notes = (await listNotes(projectRoot, {
206
+ contextId: loaded.context.id,
207
+ openOnly: true,
208
+ limit: v2 ? Number.MAX_SAFE_INTEGER : options.noteLimit ?? 20,
209
+ maxChars: options.maxChars ?? 4000,
210
+ })).filter((note) => !v2 || v2.noteIds.has(note.noteId)).slice(0, options.noteLimit ?? 20);
211
+ const v2Knowledge = query === null || !v2 ? null : discoverV2Knowledge(v2.retrieval, query, v2.currentGit, {
216
212
  scope: options.scope,
217
213
  limit: options.knowledgeLimit ?? 10,
218
214
  maxChars: options.maxChars ?? 4000,
219
215
  });
220
- const applicableDeadends = query === null
221
- ? emptyDeadends('')
222
- : v2Deadends?.result ?? await discoverDeadends(projectRoot, query, {
216
+ const v2Deadends = query === null || !v2 ? null : discoverV2Deadends(v2.retrieval, query, v2.currentGit, {
223
217
  scope: options.scope,
224
218
  limit: options.deadendLimit ?? 10,
225
219
  maxChars: options.maxChars ?? 4000,
226
220
  });
227
- const budget = { remaining: Math.max(0, options.maxChars ?? 4000) };
228
- const resumeSummary = {
229
- progressSummary: limited(loaded.resumeSummary.progressSummary, budget),
230
- lastError: loaded.resumeSummary.lastError === null ? null : limited(loaded.resumeSummary.lastError, budget),
231
- completed: limitedList(loaded.resumeSummary.completed, budget),
232
- pendingTasks: limitedList(loaded.resumeSummary.pendingTasks, budget),
233
- decisions: limitedList(loaded.resumeSummary.decisions, budget),
234
- risks: limitedList(loaded.resumeSummary.risks, budget),
235
- relatedFiles: limitedList(loaded.resumeSummary.relatedFiles, budget),
236
- branchOrCommitDrift: loaded.resumeSummary.branchOrCommitDrift,
237
- };
238
- const resumeTruncated = textLength(resumeSummary.progressSummary) < textLength(loaded.resumeSummary.progressSummary)
239
- || textLength(resumeSummary.lastError) < textLength(loaded.resumeSummary.lastError)
240
- || textLength(resumeSummary.completed) < textLength(loaded.resumeSummary.completed)
241
- || textLength(resumeSummary.pendingTasks) < textLength(loaded.resumeSummary.pendingTasks)
242
- || textLength(resumeSummary.decisions) < textLength(loaded.resumeSummary.decisions)
243
- || textLength(resumeSummary.risks) < textLength(loaded.resumeSummary.risks)
244
- || textLength(resumeSummary.relatedFiles) < textLength(loaded.resumeSummary.relatedFiles);
245
- const limitedKnowledgeResult = limitKnowledge(applicableKnowledge, budget);
246
- const limitedDeadendResult = limitDeadends(applicableDeadends, budget);
247
- const limitedNotesResult = limitNotes(notes, budget);
248
- return {
249
- status: 'ready',
250
- projectRoot,
251
- query,
252
- context: {
253
- id: loaded.context.id,
254
- title: loaded.context.title,
255
- status: loaded.context.status,
256
- branch: loaded.context.branch,
257
- commit: loaded.context.commit,
258
- updatedAt: loaded.context.updatedAt,
259
- },
260
- snapshot: {
261
- snapshotId: loaded.snapshot.snapshotId,
262
- mode: loaded.snapshot.mode,
263
- createdAt: loaded.snapshot.createdAt,
264
- branch: loaded.snapshot.branch,
265
- commit: loaded.snapshot.commit,
266
- },
267
- resumeSummary,
268
- knowledge: limitedKnowledgeResult.result,
269
- deadends: limitedDeadendResult.result,
270
- notes: limitedNotesResult.notes,
271
- truncated: resumeTruncated || limitedKnowledgeResult.truncated || limitedDeadendResult.truncated || limitedNotesResult.truncated,
272
- explanation: {
273
- mode: v2 ? 'v2' : 'v1-fallback',
274
- selection: v2?.selection ?? 'v1',
275
- retrieval: {
276
- knowledge: v2Knowledge?.exclusions ?? {},
277
- deadends: v2Deadends?.exclusions ?? {},
221
+ const applicableKnowledge = query === null
222
+ ? emptyKnowledge('')
223
+ : v2Knowledge?.result ?? await discoverKnowledge(projectRoot, query, {
224
+ scope: options.scope,
225
+ limit: options.knowledgeLimit ?? 10,
226
+ maxChars: options.maxChars ?? 4000,
227
+ });
228
+ const applicableDeadends = query === null
229
+ ? emptyDeadends('')
230
+ : v2Deadends?.result ?? await discoverDeadends(projectRoot, query, {
231
+ scope: options.scope,
232
+ limit: options.deadendLimit ?? 10,
233
+ maxChars: options.maxChars ?? 4000,
234
+ });
235
+ const budget = { remaining: Math.max(0, options.maxChars ?? 4000) };
236
+ const resumeSummary = {
237
+ progressSummary: limited(loaded.resumeSummary.progressSummary, budget),
238
+ lastError: loaded.resumeSummary.lastError === null ? null : limited(loaded.resumeSummary.lastError, budget),
239
+ completed: limitedList(loaded.resumeSummary.completed, budget),
240
+ pendingTasks: limitedList(loaded.resumeSummary.pendingTasks, budget),
241
+ decisions: limitedList(loaded.resumeSummary.decisions, budget),
242
+ risks: limitedList(loaded.resumeSummary.risks, budget),
243
+ relatedFiles: limitedList(loaded.resumeSummary.relatedFiles, budget),
244
+ branchOrCommitDrift: loaded.resumeSummary.branchOrCommitDrift,
245
+ };
246
+ const resumeTruncated = textLength(resumeSummary.progressSummary) < textLength(loaded.resumeSummary.progressSummary)
247
+ || textLength(resumeSummary.lastError) < textLength(loaded.resumeSummary.lastError)
248
+ || textLength(resumeSummary.completed) < textLength(loaded.resumeSummary.completed)
249
+ || textLength(resumeSummary.pendingTasks) < textLength(loaded.resumeSummary.pendingTasks)
250
+ || textLength(resumeSummary.decisions) < textLength(loaded.resumeSummary.decisions)
251
+ || textLength(resumeSummary.risks) < textLength(loaded.resumeSummary.risks)
252
+ || textLength(resumeSummary.relatedFiles) < textLength(loaded.resumeSummary.relatedFiles);
253
+ const limitedKnowledgeResult = limitKnowledge(applicableKnowledge, budget);
254
+ const limitedDeadendResult = limitDeadends(applicableDeadends, budget);
255
+ const limitedNotesResult = limitNotes(notes, budget);
256
+ return {
257
+ status: 'ready',
258
+ projectRoot,
259
+ query,
260
+ context: {
261
+ id: loaded.context.id,
262
+ title: loaded.context.title,
263
+ status: loaded.context.status,
264
+ branch: loaded.context.branch,
265
+ commit: loaded.context.commit,
266
+ updatedAt: loaded.context.updatedAt,
267
+ },
268
+ snapshot: {
269
+ snapshotId: loaded.snapshot.snapshotId,
270
+ mode: loaded.snapshot.mode,
271
+ createdAt: loaded.snapshot.createdAt,
272
+ branch: loaded.snapshot.branch,
273
+ commit: loaded.snapshot.commit,
278
274
  },
279
- truncation: {
280
- summary: resumeTruncated,
281
- knowledge: limitedKnowledgeResult.truncated,
282
- deadends: limitedDeadendResult.truncated,
283
- notes: limitedNotesResult.truncated,
275
+ resumeSummary,
276
+ knowledge: limitedKnowledgeResult.result,
277
+ deadends: limitedDeadendResult.result,
278
+ notes: limitedNotesResult.notes,
279
+ truncated: resumeTruncated || limitedKnowledgeResult.truncated || limitedDeadendResult.truncated || limitedNotesResult.truncated,
280
+ explanation: {
281
+ mode: v2 ? 'v2' : 'v1-fallback',
282
+ selection: v2?.selection ?? 'v1',
283
+ retrieval: {
284
+ knowledge: v2Knowledge?.exclusions ?? {},
285
+ deadends: v2Deadends?.exclusions ?? {},
286
+ },
287
+ truncation: {
288
+ summary: resumeTruncated,
289
+ knowledge: limitedKnowledgeResult.truncated,
290
+ deadends: limitedDeadendResult.truncated,
291
+ notes: limitedNotesResult.truncated,
292
+ },
284
293
  },
285
- },
294
+ };
286
295
  };
296
+ return interfaceName === null
297
+ ? action()
298
+ : withUsageTelemetry(startPath, 'orient', 'agent', options, action, interfaceName);
299
+ }
300
+ export async function contextUsageReport(startPath, options = {}) {
301
+ return buildUsageReport(startPath, options);
287
302
  }
288
303
  export async function recordContextUse(startPath, input) {
289
304
  return recordUse(startPath, input);
@@ -1,6 +1,7 @@
1
1
  export * from './api.js';
2
2
  export type { ContextOrientContext, ContextUseInput, ContextOrientExplanation, ContextOrientNoContextResult, ContextOrientOptions, ContextOrientReadyResult, ContextOrientResult, ContextOrientSnapshot, } from './types.js';
3
3
  export type { AgentLoadOptions, AgentLoadResult, AgentSaveResult, ContextSearchHit, ContextSearchOptions, ContextSearchResult, } from '../runtime/types.js';
4
+ export { contextCompile } from './api.js';
4
5
  export { compileContext } from '../compiler/compile.js';
5
6
  export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
6
7
  export type { ContextActivityRecord, ContextAnchor, ContextBudget, ContextBudgetReport, ContextCandidate, ContextCandidateSection, ContextCompileOptions, ContextExpandInput, ContextExpandLevel, ContextExpandResult, ContextExclusion, ContextExclusionReason, ContextIntent, ContextIntentSignal, ContextLevel, ContextManifest, ContextProvenance, ContextScenario, ContextSourceDescriptor, ContextTruncation, } from '../compiler/types.js';
@@ -1,3 +1,4 @@
1
1
  export * from './api.js';
2
+ export { contextCompile } from './api.js';
2
3
  export { compileContext } from '../compiler/compile.js';
3
4
  export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { checkpoint, resume } from '../runtime/runtime.js';
3
- import { contextOrient } from '../agent/api.js';
4
- import { compileContext } from '../compiler/compile.js';
5
- import { contextExpand } from '../agent/api.js';
2
+ import { checkpoint } from '../runtime/runtime.js';
3
+ import { contextCompile, contextExpand, contextOrient, contextUsageReport, loadContext } from '../agent/api.js';
4
+ import { formatUsageReport } from '../cognition/usage-report.js';
6
5
  import { importV1, verifyV1Import } from '../cognition/migration-v1.js';
7
6
  import { recordUse } from '../cognition/feedback.js';
8
7
  import { verifyJournal } from '../cognition/journal.js';
@@ -82,6 +81,34 @@ Initialize the local .context runtime layout. Re-running init is safe.
82
81
 
83
82
  Options:
84
83
  --path <path> Project path`,
84
+ usage: `usage: ctx usage report [options]
85
+
86
+ Show read-only context asset, reuse, token investment, and operation metrics.
87
+
88
+ Options:
89
+ --path <path> Project path
90
+ --since <ISO> Window start
91
+ --until <ISO> Window end
92
+ --days <number> Rolling window length (default: 30)
93
+ --month <YYYY-MM> Calendar month window
94
+ --granularity <value> day|week|month
95
+ --format <value> json|ascii (default: json)
96
+ --baseline-tokens <number> Explicit comparison token count
97
+ --baseline-source <value> manual|recorded-control`,
98
+ 'usage report': `usage: ctx usage report [options]
99
+
100
+ Show read-only context asset, reuse, token investment, and operation metrics.
101
+
102
+ Options:
103
+ --path <path> Project path
104
+ --since <ISO> Window start
105
+ --until <ISO> Window end
106
+ --days <number> Rolling window length (default: 30)
107
+ --month <YYYY-MM> Calendar month window
108
+ --granularity <value> day|week|month
109
+ --format <value> json|ascii (default: json)
110
+ --baseline-tokens <number> Explicit comparison token count
111
+ --baseline-source <value> manual|recorded-control`,
85
112
  checkpoint: `usage: ctx checkpoint [options]
86
113
 
87
114
  Save structured work state as a baseline or incremental snapshot.
@@ -161,7 +188,7 @@ Start an MCP stdio server for Agent integrations.
161
188
  Run "ctx agent serve --help" for details.`,
162
189
  'agent serve': `usage: ctx agent serve
163
190
 
164
- Start an MCP stdio server exposing context_orient, context_expand, context_compile, context_resume, context_note_list, context_note_add, and context_checkpoint tools.
191
+ Start an MCP stdio server exposing context_orient, context_expand, context_compile, context_resume, context_note_list, context_note_add, context_usage_report, and context_checkpoint tools.
165
192
 
166
193
  The server owns standard input/output; do not use it interactively.`,
167
194
  hook: `usage: ctx hook claude-code session-start|user-prompt
@@ -363,7 +390,7 @@ Stop the local runtime through authenticated loopback IPC.`,
363
390
  Internal runtime daemon entry point.`,
364
391
  };
365
392
  function usage() {
366
- return `usage: ctx init|checkpoint|resume|orient|compile|expand|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|journal [options]
393
+ return `usage: ctx init|checkpoint|resume|orient|compile|expand|usage|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|journal [options]
367
394
 
368
395
  Run \"ctx <command> --help\" for command details.`;
369
396
  }
@@ -392,7 +419,7 @@ function printHelp(args) {
392
419
  || (command === 'note' && ['add', 'list'].includes(args[1]))
393
420
  || (command === 'migrate' && ['v1', 'verify'].includes(args[1]))
394
421
  || (command === 'runtime' && ['enable', 'disable', 'start', 'stop', 'status', 'serve'].includes(args[1]))
395
- || (command === 'filesystem' && ['enable', 'disable', 'status'].includes(args[1]));
422
+ || (command === 'usage' && args[1] === 'report');
396
423
  const key = nested ? `${command} ${args[1]}` : command;
397
424
  const nestedKey = key === 'integrate claude' && (args[2] === 'inspect' || args[2] === 'install') ? `${key} ${args[2]}` : key;
398
425
  const help = HELP[nestedKey];
@@ -450,7 +477,7 @@ async function run(args) {
450
477
  case 'resume':
451
478
  validateOptions(args.slice(1), ['--path', '--context', '--max-chars'], ['--path', '--context', '--max-chars']);
452
479
  validatePositionals(positionals(args.slice(1), ['--path', '--context', '--max-chars']), 0, HELP.resume);
453
- print(await resume(target, option(args, '--context'), numericOption(args, '--max-chars', 4000)));
480
+ print(await loadContext(target, { contextId: option(args, '--context'), maxChars: numericOption(args, '--max-chars', 4000) }, 'cli'));
454
481
  return;
455
482
  case 'orient': {
456
483
  const valueOptions = ['--path', '--context', '--scope', '--max-chars', '--knowledge-limit', '--deadend-limit', '--note-limit'];
@@ -466,7 +493,7 @@ async function run(args) {
466
493
  knowledgeLimit: numericOption(args, '--knowledge-limit', 10),
467
494
  deadendLimit: numericOption(args, '--deadend-limit', 10),
468
495
  noteLimit: numericOption(args, '--note-limit', 20),
469
- }));
496
+ }, 'cli'));
470
497
  return;
471
498
  }
472
499
  case 'compile': {
@@ -474,7 +501,7 @@ async function run(args) {
474
501
  validateOptions(args.slice(1), valueOptions, valueOptions);
475
502
  const query = positionals(args.slice(1), valueOptions);
476
503
  validatePositionals(query, 1, HELP.compile);
477
- print(await compileContext(target, {
504
+ print(await contextCompile(target, {
478
505
  contextId: option(args, '--context'),
479
506
  query: query[0],
480
507
  scenario: enumOption(args, '--scenario', ['resume', 'implementation', 'debugging', 'verification', 'handoff', 'exploration', 'unknown']),
@@ -485,7 +512,7 @@ async function run(args) {
485
512
  deadendLimit: numericOption(args, '--deadend-limit', 10),
486
513
  noteLimit: numericOption(args, '--note-limit', 20),
487
514
  activityLimit: numericOption(args, '--activity-limit', 20),
488
- }));
515
+ }, 'cli'));
489
516
  return;
490
517
  }
491
518
  case 'expand': {
@@ -521,6 +548,27 @@ async function run(args) {
521
548
  }));
522
549
  return;
523
550
  }
551
+ case 'usage': {
552
+ if (args[1] !== 'report')
553
+ throw new Error(HELP.usage);
554
+ const valueOptions = ['--path', '--since', '--until', '--days', '--month', '--granularity', '--format', '--baseline-tokens', '--baseline-source'];
555
+ validateOptions(args.slice(2), valueOptions, valueOptions);
556
+ validatePositionals(positionals(args.slice(2), valueOptions), 0, HELP['usage report']);
557
+ const report = await contextUsageReport(target, {
558
+ since: option(args, '--since'),
559
+ until: option(args, '--until'),
560
+ days: option(args, '--days') === undefined ? undefined : numericOption(args, '--days', 30),
561
+ month: option(args, '--month'),
562
+ granularity: enumOption(args, '--granularity', ['day', 'week', 'month']),
563
+ baselineTokens: option(args, '--baseline-tokens') === undefined ? undefined : numericOption(args, '--baseline-tokens', 0),
564
+ baselineSource: enumOption(args, '--baseline-source', ['manual', 'recorded-control']),
565
+ });
566
+ if (enumOption(args, '--format', ['json', 'ascii']) === 'ascii')
567
+ process.stdout.write(formatUsageReport(report));
568
+ else
569
+ print(report);
570
+ return;
571
+ }
524
572
  case 'agent':
525
573
  if (args[1] !== 'serve')
526
574
  throw new Error(HELP.agent);
@@ -11,9 +11,10 @@ const eventTypes = new Set([
11
11
  'context.checkpoint.imported', 'context.state.recorded', 'note.recorded', 'note.resolved', 'note.absorbed',
12
12
  'knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected',
13
13
  'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected', 'record.used',
14
+ 'usage.operation.completed',
14
15
  'projection.advanced', 'projection.failed',
15
16
  ]);
16
- const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'git-observer', 'fs-observer', 'migration']);
17
+ const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'agent', 'git-observer', 'fs-observer', 'migration']);
17
18
  const segmentName = '00000001.ndjson';
18
19
  function canonical(value) {
19
20
  if (value === null || typeof value !== 'object')
@@ -65,6 +66,17 @@ function validatePayloadBoundary(input) {
65
66
  if (!isRecord(record) || typeof record.message !== 'string' || record.message.trim().length === 0)
66
67
  throw new Error('note message must not be empty');
67
68
  }
69
+ if (input.type === 'usage.operation.completed') {
70
+ const requiredNumbers = ['durationMs', 'inputChars', 'outputChars', 'inputBytes', 'outputBytes', 'estimatedInputTokens', 'estimatedOutputTokens'];
71
+ if (payload.operation !== 'orient' && payload.operation !== 'compile' && payload.operation !== 'expand' && payload.operation !== 'resume')
72
+ throw new Error('invalid usage operation');
73
+ if (payload.interface !== 'cli' && payload.interface !== 'mcp' && payload.interface !== 'agent' && payload.interface !== 'hook')
74
+ throw new Error('invalid usage interface');
75
+ if (typeof payload.success !== 'boolean' || payload.estimatedMethod !== 'chars-div-four' || requiredNumbers.some((key) => typeof payload[key] !== 'number' || !Number.isFinite(payload[key]) || payload[key] < 0))
76
+ throw new Error('invalid usage telemetry payload');
77
+ if (!payload.success && typeof payload.errorClass !== 'string')
78
+ throw new Error('usage errors require an error class');
79
+ }
68
80
  if (input.type === 'workspace.paths.changed') {
69
81
  const changes = payload.changes;
70
82
  if (!Array.isArray(changes) || changes.length === 0)
@@ -1,6 +1,6 @@
1
1
  export declare const COGNITION_LAYOUT_VERSION: "event-journal-v2";
2
- export type CognitionEventSource = 'cli' | 'claude-hook' | 'mcp' | 'git-observer' | 'fs-observer' | 'migration';
3
- export type CognitionEventType = 'runtime.enabled' | 'runtime.started' | 'runtime.stopped' | 'host.session.started' | 'host.prompt.submitted' | 'git.state.observed' | 'git.branch.changed' | 'git.head.changed' | 'workspace.paths.changed' | 'context.checkpoint.imported' | 'context.state.recorded' | 'note.recorded' | 'note.resolved' | 'note.absorbed' | 'knowledge.proposed' | 'knowledge.verified' | 'knowledge.deprecated' | 'knowledge.rejected' | 'deadend.proposed' | 'deadend.verified' | 'deadend.obsoleted' | 'deadend.rejected' | 'record.used' | 'projection.advanced' | 'projection.failed';
2
+ export type CognitionEventSource = 'cli' | 'claude-hook' | 'mcp' | 'agent' | 'git-observer' | 'fs-observer' | 'migration';
3
+ export type CognitionEventType = 'runtime.enabled' | 'runtime.started' | 'runtime.stopped' | 'host.session.started' | 'host.prompt.submitted' | 'git.state.observed' | 'git.branch.changed' | 'git.head.changed' | 'workspace.paths.changed' | 'context.checkpoint.imported' | 'context.state.recorded' | 'note.recorded' | 'note.resolved' | 'note.absorbed' | 'knowledge.proposed' | 'knowledge.verified' | 'knowledge.deprecated' | 'knowledge.rejected' | 'deadend.proposed' | 'deadend.verified' | 'deadend.obsoleted' | 'deadend.rejected' | 'record.used' | 'usage.operation.completed' | 'projection.advanced' | 'projection.failed';
4
4
  export interface CognitionGitAnchor {
5
5
  worktreeId: string | null;
6
6
  branch: string | null;
@@ -67,6 +67,20 @@ export interface CognitionUseFeedback {
67
67
  entityId: string;
68
68
  outcome: 'used';
69
69
  }
70
+ export interface UsageOperationCompletedPayload {
71
+ operation: 'orient' | 'compile' | 'expand' | 'resume';
72
+ interface: 'cli' | 'mcp' | 'agent' | 'hook';
73
+ success: boolean;
74
+ durationMs: number;
75
+ inputChars: number;
76
+ outputChars: number;
77
+ inputBytes: number;
78
+ outputBytes: number;
79
+ estimatedInputTokens: number;
80
+ estimatedOutputTokens: number;
81
+ estimatedMethod: 'chars-div-four';
82
+ errorClass?: string;
83
+ }
70
84
  export interface CognitionRetrievalEntry {
71
85
  entity: 'knowledge' | 'deadend';
72
86
  entityId: string;
@@ -0,0 +1,40 @@
1
+ export type UsageReportFormat = 'json' | 'ascii';
2
+ export type UsageGranularity = 'day' | 'week' | 'month';
3
+ export interface UsageReportOptions {
4
+ since?: string;
5
+ until?: string;
6
+ days?: number;
7
+ month?: string;
8
+ granularity?: UsageGranularity;
9
+ baselineTokens?: number;
10
+ baselineSource?: 'manual' | 'recorded-control';
11
+ }
12
+ export interface UsageMetric<T> {
13
+ value: T | null;
14
+ quality: 'measured' | 'estimated' | 'derived' | 'unavailable';
15
+ unit: string;
16
+ method?: string;
17
+ }
18
+ interface Window {
19
+ from: string;
20
+ to: string;
21
+ kind: 'rolling' | 'calendar-month' | 'explicit';
22
+ granularity: UsageGranularity;
23
+ }
24
+ export interface UsageReport {
25
+ schemaVersion: 1;
26
+ generatedAt: string;
27
+ projectRoot: string;
28
+ window: Window;
29
+ dataQuality: Record<string, unknown>;
30
+ assets: Record<string, unknown>;
31
+ reuse: Record<string, unknown>;
32
+ investment: Record<string, unknown>;
33
+ payback: Record<string, unknown>;
34
+ operations: Record<string, unknown>;
35
+ trends: Array<Record<string, unknown>>;
36
+ warnings: string[];
37
+ }
38
+ export declare function buildUsageReport(startPath: string | undefined, options?: UsageReportOptions): Promise<UsageReport>;
39
+ export declare function formatUsageReport(report: Record<string, any>): string;
40
+ export {};
@@ -0,0 +1,232 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { resolveProjectRoot } from '../project/project-resolver.js';
3
+ import { readJson, isRecord } from '../storage/json-store.js';
4
+ import { contextsRoot, contextMetadataPath, snapshotsDirectory } from '../storage/layout.js';
5
+ import { readJournal } from './journal.js';
6
+ import { listDeadends, listKnowledge } from '../runtime/knowledge.js';
7
+ import { listNotes } from '../runtime/notes.js';
8
+ function metric(value, quality, unit, method) {
9
+ return { value, quality, unit, ...(method ? { method } : {}) };
10
+ }
11
+ function iso(value) {
12
+ const time = Date.parse(value);
13
+ if (!Number.isFinite(time))
14
+ throw new Error(`invalid ISO date: ${value}`);
15
+ return new Date(time).toISOString();
16
+ }
17
+ function windowOf(options, now = new Date()) {
18
+ if (options.month && !/^\d{4}-\d{2}$/.test(options.month))
19
+ throw new Error('--month must be YYYY-MM');
20
+ if (options.since && options.month)
21
+ throw new Error('--since and --month cannot be combined');
22
+ if (options.days !== undefined && (!Number.isInteger(options.days) || options.days < 0))
23
+ throw new Error('--days must be a non-negative integer');
24
+ const to = options.until ? iso(options.until) : now.toISOString();
25
+ const toMs = Date.parse(to);
26
+ if (options.month) {
27
+ const [year, month] = options.month.split('-').map(Number);
28
+ const from = new Date(Date.UTC(year, month - 1, 1));
29
+ const end = new Date(Date.UTC(year, month, 1));
30
+ if (month < 1 || month > 12 || end.getUTCMonth() !== month % 12)
31
+ throw new Error('--month must be a valid calendar month');
32
+ return { from: from.toISOString(), to: end.toISOString(), kind: 'calendar-month', granularity: options.granularity ?? 'week' };
33
+ }
34
+ const from = options.since ? iso(options.since) : new Date(toMs - (options.days ?? 30) * 86_400_000).toISOString();
35
+ if (Date.parse(from) > toMs)
36
+ throw new Error('--since must not be after --until');
37
+ return { from, to, kind: options.since || options.until ? 'explicit' : 'rolling', granularity: options.granularity ?? 'day' };
38
+ }
39
+ function inWindow(event, window) {
40
+ const time = Date.parse(event.occurredAt);
41
+ return time >= Date.parse(window.from) && time < Date.parse(window.to);
42
+ }
43
+ function dayStart(value, granularity) {
44
+ const date = new Date(value);
45
+ if (granularity === 'month')
46
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)).toISOString();
47
+ if (granularity === 'week') {
48
+ const day = date.getUTCDay();
49
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - (day === 0 ? 6 : day - 1))).toISOString();
50
+ }
51
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString();
52
+ }
53
+ async function contexts(projectRoot) {
54
+ let ids = [];
55
+ try {
56
+ ids = await readdir(contextsRoot(projectRoot));
57
+ }
58
+ catch {
59
+ return { items: [], snapshots: 0 };
60
+ }
61
+ const items = [];
62
+ let snapshots = 0;
63
+ for (const id of ids) {
64
+ try {
65
+ const item = await readJson(contextMetadataPath(projectRoot, id), (value) => isRecord(value) && value.schemaVersion === 1 && value.id === id);
66
+ items.push(item);
67
+ snapshots += (await readdir(snapshotsDirectory(projectRoot, id))).filter((file) => file.endsWith('.json')).length;
68
+ }
69
+ catch { /* invalid files are reported by doctor */ }
70
+ }
71
+ return { items, snapshots };
72
+ }
73
+ function usagePayload(event) {
74
+ if (event.type !== 'usage.operation.completed' || !isRecord(event.payload))
75
+ return null;
76
+ const payload = event.payload;
77
+ return (payload.operation === 'orient' || payload.operation === 'compile' || payload.operation === 'expand' || payload.operation === 'resume')
78
+ && (payload.interface === 'cli' || payload.interface === 'mcp' || payload.interface === 'agent' || payload.interface === 'hook')
79
+ && typeof payload.success === 'boolean'
80
+ && typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) && payload.durationMs >= 0
81
+ && typeof payload.inputChars === 'number' && Number.isFinite(payload.inputChars) && payload.inputChars >= 0
82
+ && typeof payload.outputChars === 'number' && Number.isFinite(payload.outputChars) && payload.outputChars >= 0
83
+ && typeof payload.inputBytes === 'number' && Number.isFinite(payload.inputBytes) && payload.inputBytes >= 0
84
+ && typeof payload.outputBytes === 'number' && Number.isFinite(payload.outputBytes) && payload.outputBytes >= 0
85
+ && typeof payload.estimatedInputTokens === 'number' && Number.isFinite(payload.estimatedInputTokens) && payload.estimatedInputTokens >= 0
86
+ && typeof payload.estimatedOutputTokens === 'number' && Number.isFinite(payload.estimatedOutputTokens) && payload.estimatedOutputTokens >= 0
87
+ && payload.estimatedMethod === 'chars-div-four'
88
+ ? payload : null;
89
+ }
90
+ function percentile(values, fraction) {
91
+ if (values.length === 0)
92
+ return null;
93
+ const sorted = [...values].sort((a, b) => a - b);
94
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)];
95
+ }
96
+ function operationStats(events) {
97
+ const groups = new Map();
98
+ for (const event of events) {
99
+ const payload = usagePayload(event);
100
+ if (!payload)
101
+ continue;
102
+ const key = payload.operation;
103
+ groups.set(key, [...(groups.get(key) ?? []), payload]);
104
+ }
105
+ const byOperation = {};
106
+ for (const [operation, values] of groups) {
107
+ const durations = values.map((item) => item.durationMs);
108
+ byOperation[operation] = {
109
+ calls: values.length,
110
+ successes: values.filter((item) => item.success).length,
111
+ failures: values.filter((item) => !item.success).length,
112
+ successRate: values.filter((item) => item.success).length / values.length,
113
+ errorRate: values.filter((item) => !item.success).length / values.length,
114
+ durationMs: { average: durations.reduce((sum, value) => sum + value, 0) / values.length, p50: percentile(durations, 0.5), p95: percentile(durations, 0.95), max: Math.max(...durations) },
115
+ inputChars: values.reduce((sum, item) => sum + item.inputChars, 0),
116
+ outputChars: values.reduce((sum, item) => sum + item.outputChars, 0),
117
+ inputBytes: values.reduce((sum, item) => sum + item.inputBytes, 0),
118
+ outputBytes: values.reduce((sum, item) => sum + item.outputBytes, 0),
119
+ estimatedInputTokens: values.reduce((sum, item) => sum + item.estimatedInputTokens, 0),
120
+ estimatedOutputTokens: values.reduce((sum, item) => sum + item.estimatedOutputTokens, 0),
121
+ };
122
+ }
123
+ return byOperation;
124
+ }
125
+ export async function buildUsageReport(startPath, options = {}) {
126
+ const projectRoot = await resolveProjectRoot(startPath);
127
+ const window = windowOf(options);
128
+ let events = [];
129
+ let journalVerified = true;
130
+ try {
131
+ events = await readJournal(projectRoot);
132
+ }
133
+ catch (error) {
134
+ if (!(error instanceof Error && /ENOENT|manifest|not initialized/i.test(error.message)))
135
+ throw error;
136
+ journalVerified = false;
137
+ }
138
+ const scoped = events.filter((event) => inWindow(event, window));
139
+ const usageEvents = scoped.map((event) => ({ event, payload: usagePayload(event) })).filter((item) => item.payload !== null);
140
+ const knowledge = await listKnowledge(projectRoot, true);
141
+ const deadends = await listDeadends(projectRoot, true);
142
+ const notes = await listNotes(projectRoot, { limit: Number.MAX_SAFE_INTEGER, maxChars: 0 });
143
+ const contextData = await contexts(projectRoot);
144
+ const used = new Map();
145
+ for (const event of events.filter((item) => item.type === 'record.used')) {
146
+ const payload = event.payload;
147
+ if (typeof payload.entity === 'string' && typeof payload.entityId === 'string')
148
+ used.set(`${payload.entity}:${payload.entityId}`, (used.get(`${payload.entity}:${payload.entityId}`) ?? 0) + 1);
149
+ }
150
+ const verified = [...knowledge.filter((item) => item.status === 'verified').map((item) => `knowledge:${item.knowledgeId}`), ...deadends.filter((item) => item.status === 'verified').map((item) => `deadend:${item.deadendId}`)];
151
+ const usedVerified = verified.filter((key) => (used.get(key) ?? 0) > 0).length;
152
+ const recordUsed = scoped.filter((event) => event.type === 'record.used').length;
153
+ const lifecycle = (type) => scoped.filter((event) => event.type === type).length;
154
+ const buckets = new Map();
155
+ for (const event of scoped) {
156
+ const key = dayStart(event.occurredAt, window.granularity);
157
+ const bucket = buckets.get(key) ?? { operations: 0, usageOperations: 0, recordsUsed: 0, outputChars: 0, estimatedOutputTokens: 0, knowledgeCreated: 0, knowledgeVerified: 0, deadendsCreated: 0, deadendsVerified: 0 };
158
+ const payload = usagePayload(event);
159
+ if (payload) {
160
+ bucket.operations += 1;
161
+ bucket.usageOperations += 1;
162
+ bucket.outputChars += payload.outputChars;
163
+ bucket.estimatedOutputTokens += payload.estimatedOutputTokens;
164
+ }
165
+ if (event.type === 'record.used')
166
+ bucket.recordsUsed += 1;
167
+ if (event.type === 'knowledge.proposed')
168
+ bucket.knowledgeCreated += 1;
169
+ if (event.type === 'knowledge.verified')
170
+ bucket.knowledgeVerified += 1;
171
+ if (event.type === 'deadend.proposed')
172
+ bucket.deadendsCreated += 1;
173
+ if (event.type === 'deadend.verified')
174
+ bucket.deadendsVerified += 1;
175
+ buckets.set(key, bucket);
176
+ }
177
+ const ctxTokens = usageEvents.reduce((sum, item) => sum + item.payload.estimatedOutputTokens, 0);
178
+ const baseline = options.baselineTokens ?? null;
179
+ const savings = baseline === null ? null : baseline - ctxTokens;
180
+ const investment = metric(ctxTokens, 'estimated', 'tokens', 'chars-div-four');
181
+ const rawAssets = verified.length;
182
+ const usedCount = verified.reduce((sum, key) => sum + Math.min(used.get(key) ?? 0, 3), 0);
183
+ const trendEntries = [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b));
184
+ let cumulativeInvestmentTokens = 0;
185
+ let cumulativeOutputChars = 0;
186
+ let paybackPeriodStart = null;
187
+ const trends = trendEntries.map(([periodStart, values]) => {
188
+ cumulativeInvestmentTokens += values.estimatedOutputTokens;
189
+ cumulativeOutputChars += values.outputChars;
190
+ const cumulativeSavings = baseline === null ? null : baseline - cumulativeInvestmentTokens;
191
+ if (paybackPeriodStart === null && cumulativeSavings !== null && cumulativeSavings >= cumulativeInvestmentTokens)
192
+ paybackPeriodStart = periodStart;
193
+ return {
194
+ periodStart,
195
+ ...values,
196
+ cumulativeOutputChars,
197
+ cumulativeEstimatedOutputTokens: cumulativeInvestmentTokens,
198
+ cumulativeEstimatedSavings: cumulativeSavings,
199
+ };
200
+ });
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
+ return {
204
+ 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 },
206
+ assets: {
207
+ 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
+ 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
+ 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
+ },
211
+ 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') },
213
+ payback: {
214
+ baselineTokens: baseline,
215
+ baselineSource: options.baselineSource ?? null,
216
+ 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,
221
+ },
222
+ operations: { available: usageEvents.length > 0, byOperation: operationStats(scoped), reason: usageEvents.length > 0 ? null : 'operation output telemetry is not yet recorded' },
223
+ 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'] : [])],
225
+ };
226
+ }
227
+ export function formatUsageReport(report) {
228
+ const assets = report.assets;
229
+ 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}`];
231
+ return `${lines.join('\n')}\n`;
232
+ }
@@ -0,0 +1,5 @@
1
+ import type { CognitionEventSource, UsageOperationCompletedPayload } from './types.js';
2
+ export type UsageOperation = UsageOperationCompletedPayload['operation'];
3
+ export type UsageInterface = UsageOperationCompletedPayload['interface'];
4
+ export declare function recordUsageOperation(startPath: string | undefined, operation: UsageOperation, source: CognitionEventSource, input: unknown, output: unknown, startedAt: number, success: boolean, error?: unknown, interfaceName?: UsageInterface): Promise<void>;
5
+ export declare function withUsageTelemetry<T>(startPath: string | undefined, operation: UsageOperation, source: CognitionEventSource, input: unknown, action: () => Promise<T>, interfaceName?: UsageInterface): Promise<T>;
@@ -0,0 +1,48 @@
1
+ import { appendEvent } from './journal.js';
2
+ function errorClass(error) {
3
+ return error instanceof Error ? error.constructor.name : typeof error;
4
+ }
5
+ function interfaceFor(source) {
6
+ return source === 'mcp' ? 'mcp' : source === 'claude-hook' ? 'hook' : source === 'agent' ? 'agent' : 'cli';
7
+ }
8
+ export async function recordUsageOperation(startPath, operation, source, input, output, startedAt, success, error, interfaceName = interfaceFor(source)) {
9
+ const inputText = typeof input === 'string' ? input : JSON.stringify(input ?? null);
10
+ const outputText = typeof output === 'string' ? output : JSON.stringify(output ?? null);
11
+ const payload = {
12
+ operation,
13
+ interface: interfaceName,
14
+ success,
15
+ durationMs: Math.max(0, Date.now() - startedAt),
16
+ inputChars: inputText.length,
17
+ outputChars: outputText.length,
18
+ inputBytes: Buffer.byteLength(inputText),
19
+ outputBytes: Buffer.byteLength(outputText),
20
+ estimatedInputTokens: Math.ceil(inputText.length / 4),
21
+ estimatedOutputTokens: Math.ceil(outputText.length / 4),
22
+ estimatedMethod: 'chars-div-four',
23
+ ...(success ? {} : { errorClass: errorClass(error) }),
24
+ };
25
+ try {
26
+ await appendEvent(startPath, {
27
+ type: 'usage.operation.completed',
28
+ source: { kind: source },
29
+ idempotencyKey: `usage:${operation}:${source}:${startedAt}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
30
+ payload,
31
+ });
32
+ }
33
+ catch {
34
+ // Observability must not affect the operation being observed.
35
+ }
36
+ }
37
+ export async function withUsageTelemetry(startPath, operation, source, input, action, interfaceName = interfaceFor(source)) {
38
+ const startedAt = Date.now();
39
+ try {
40
+ const result = await action();
41
+ await recordUsageOperation(startPath, operation, source, input, result, startedAt, true, undefined, interfaceName);
42
+ return result;
43
+ }
44
+ catch (error) {
45
+ await recordUsageOperation(startPath, operation, source, input, { error: errorClass(error) }, startedAt, false, error, interfaceName);
46
+ throw error;
47
+ }
48
+ }
@@ -321,7 +321,7 @@ export async function compileContext(startPath, options = {}) {
321
321
  });
322
322
  return {
323
323
  schemaVersion: 1,
324
- compilerVersion: '0.7.6',
324
+ compilerVersion: '0.7.7',
325
325
  rankingVersion: 'compiler-ranking-v2',
326
326
  status: orientation.status,
327
327
  projectRoot: sources.anchor.projectRoot,
@@ -16,7 +16,7 @@ export function estimateTokens(value) {
16
16
  export function rebuildManifestHash(manifest) {
17
17
  return contentHash({
18
18
  schemaVersion: 1,
19
- compilerVersion: '0.7.6',
19
+ compilerVersion: '0.7.7',
20
20
  rankingVersion: 'compiler-ranking-v2',
21
21
  status: manifest.status,
22
22
  projectRoot: manifest.projectRoot,
@@ -93,7 +93,7 @@ export async function loadCompilerSources(startPath, options) {
93
93
  knowledgeLimit: options.knowledgeLimit ?? 10,
94
94
  deadendLimit: options.deadendLimit ?? 10,
95
95
  noteLimit: options.noteLimit ?? 20,
96
- });
96
+ }, null);
97
97
  const projectRoot = orientation.projectRoot;
98
98
  const git = await readGitWorktreeState(projectRoot);
99
99
  let events = [];
@@ -201,7 +201,7 @@ export interface ContextExpandResult {
201
201
  }
202
202
  export interface ContextManifest {
203
203
  schemaVersion: 1;
204
- compilerVersion: '0.7.6';
204
+ compilerVersion: '0.7.7';
205
205
  rankingVersion: 'compiler-ranking-v2';
206
206
  status: 'ready' | 'no_context';
207
207
  projectRoot: string;
@@ -1,8 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { compileContext } from '../compiler/compile.js';
4
3
  import { addNote, listNotes } from '../runtime/notes.js';
5
- import { contextExpand, contextOrient, loadContext, saveContext } from '../agent/api.js';
4
+ import { contextCompile, contextExpand, contextOrient, contextUsageReport, loadContext, saveContext } from '../agent/api.js';
6
5
  import { ensureRuntimeForSession } from '../cognition/runtime.js';
7
6
  import { VERSION } from '../version.js';
8
7
  const orientInput = z.object({
@@ -84,6 +83,16 @@ const checkpointInput = z.object({
84
83
  risks: z.array(z.string()).optional(),
85
84
  relatedFiles: z.array(z.string()).optional(),
86
85
  });
86
+ const usageReportInput = z.object({
87
+ path: z.string().optional(),
88
+ since: z.string().optional(),
89
+ until: z.string().optional(),
90
+ days: z.int().nonnegative().optional(),
91
+ month: z.string().optional(),
92
+ granularity: z.enum(['day', 'week', 'month']).optional(),
93
+ baselineTokens: z.number().nonnegative().optional(),
94
+ baselineSource: z.enum(['manual', 'recorded-control']).optional(),
95
+ });
87
96
  function errorMessage(error) {
88
97
  return error instanceof Error ? error.message : String(error);
89
98
  }
@@ -108,7 +117,7 @@ export function createMcpServer() {
108
117
  }, async ({ path, ...options }) => {
109
118
  try {
110
119
  await ensureRuntimeForSession(path, { source: 'mcp', timeoutMs: 1_000 }).catch(() => undefined);
111
- return successResponse(await contextOrient(path, options));
120
+ return successResponse(await contextOrient(path, options, 'mcp'));
112
121
  }
113
122
  catch (error) {
114
123
  return failureResponse('context_orient', error);
@@ -121,7 +130,7 @@ export function createMcpServer() {
121
130
  }, async ({ path, ...input }) => {
122
131
  try {
123
132
  await ensureRuntimeForSession(path, { source: 'mcp', timeoutMs: 1_000 }).catch(() => undefined);
124
- return successResponse(await contextExpand(path, input));
133
+ return successResponse(await contextExpand(path, input, 'mcp'));
125
134
  }
126
135
  catch (error) {
127
136
  return failureResponse('context_expand', error);
@@ -134,7 +143,7 @@ export function createMcpServer() {
134
143
  }, async ({ path, ...options }) => {
135
144
  try {
136
145
  await ensureRuntimeForSession(path, { source: 'mcp', timeoutMs: 1_000 }).catch(() => undefined);
137
- return successResponse(await compileContext(path, options));
146
+ return successResponse(await contextCompile(path, options, 'mcp'));
138
147
  }
139
148
  catch (error) {
140
149
  return failureResponse('context_compile', error);
@@ -147,7 +156,7 @@ export function createMcpServer() {
147
156
  }, async ({ path, ...options }) => {
148
157
  try {
149
158
  await ensureRuntimeForSession(path, { source: 'mcp', timeoutMs: 1_000 }).catch(() => undefined);
150
- return successResponse(await loadContext(path, options));
159
+ return successResponse(await loadContext(path, options, 'mcp'));
151
160
  }
152
161
  catch (error) {
153
162
  return failureResponse('context_resume', error);
@@ -179,6 +188,18 @@ export function createMcpServer() {
179
188
  return failureResponse('context_note_add', error);
180
189
  }
181
190
  });
191
+ server.registerTool('context_usage_report', {
192
+ title: 'Context usage report',
193
+ description: 'Build a read-only report of context assets, explicit reuse, estimated token investment, and operation metrics.',
194
+ inputSchema: usageReportInput,
195
+ }, async ({ path, ...options }) => {
196
+ try {
197
+ return successResponse(await contextUsageReport(path, options));
198
+ }
199
+ catch (error) {
200
+ return failureResponse('context_usage_report', error);
201
+ }
202
+ });
182
203
  server.registerTool('context_checkpoint', {
183
204
  title: 'Checkpoint context',
184
205
  description: 'Explicitly save context progress and create a Snapshot when changes are present; runtime rate limits remain enforced.',
@@ -1 +1 @@
1
- export declare const VERSION = "0.7.6";
1
+ export declare const VERSION = "0.7.7";
@@ -1 +1 @@
1
- export const VERSION = '0.7.6';
1
+ export const VERSION = '0.7.7';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluffy-context",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Local context management CLI and MCP tools for AI coding agents",
5
5
  "license": "MIT",
6
6
  "author": "FluffyChi-Xing",
@@ -19,12 +19,16 @@ compatibility: 需要 Node.js >=20.19.0;Git 可选。CLI 通过 npm 全局安
19
19
  - 仅需要完整详情时再运行 `ctx resume --max-chars <预算>`;它会保留既有的 `lastUsedAt` 更新语义。
20
20
  - Agent 集成从 `fluffy-context` 或 `fluffy-context/agent` 导入 `contextOrient`、`compileContext`、`saveContext`、`loadContext` 和 `searchContext`,不应直接读写 `.context` 或导入内部 `dist/...` 路径。
21
21
  - `ctx compile` 的 `activity` section 只表示 Runtime 观察到的近期文件 metadata 变化;它不代表文件被读取或理解,也不替代 checkpoint、Note 或已验证 Knowledge。
22
- - 0.7.6 的 Compiler Manifest 提供稳定 `identity.manifestHash`、候选 `itemHash`、`tokenEstimate` 和 `level` 契约;默认输出 `summary` 层。
22
+ - 0.7.7 的 Compiler Manifest 提供稳定 `identity.manifestHash`、候选 `itemHash`、`tokenEstimate` 和 `level` 契约;默认输出 `summary` 层。
23
23
  - 只对 selected candidate 使用 `ctx expand --manifest-hash ... --candidate-id ... --level structured|evidence`。必须带上原始 compile 参数和相同的 compile budget;Manifest 过期时应重新 compile,不要绕过 hash 校验。
24
24
  - `expand` 是无状态、只读的渐进式展开:`complete` 才提供完整 `content`,`partial` 提供有界 `text` 且 `content` 为 null。它不写入 Context/journal、不记录 feedback、不读取项目文件正文、diff、命令输出或敏感数据。Agent API 使用 `contextExpand`,MCP 使用 `context_expand`。
25
25
  - 新任务先发现已验证共识,再开始实现;候选项只在显式审查时使用。
26
26
  - 普通查询只使用已验证 Knowledge 和 Deadend;需要审查候选项时显式使用 `--all`。
27
27
  - CLI 业务命令的标准输出是 JSON;错误写入标准错误并返回非零退出码。解析输出时不要把 `--help` 的纯文本当作 JSON。
28
+ - 每周或每月复盘时使用 `ctx usage report`,不要为每条 prompt 运行;它只读 journal 和 v1 数据,不创建事件或修改项目状态。
29
+ - Report 中的 `chars / 4` 是明确标注的 estimated token,不是真实 Provider token;Provider token 和计费金额当前 unavailable。
30
+ - 只有显式 `record.used` 才算 Knowledge/Deadend reuse;compile 命中或 orient 返回不等于复用。
31
+ - 没有显式 baseline 时,report 不生成 savings、ROI 或 payback 数字;report 不能替代 `doctor`、`journal verify` 或人工治理。
28
32
 
29
33
  ## 快速工作流
30
34
 
@@ -46,6 +50,21 @@ ctx learn ... / ctx deadend ... → 显式 verify
46
50
 
47
51
  `ctx orient` 不带查询时也有效,用于恢复当前任务和 open Note。带查询时只发现已验证的 Knowledge 与 Deadend;不要以候选项驱动普通实现。
48
52
 
53
+ ### Usage report
54
+
55
+ 在阶段性复盘、周报或月报时运行只读观测报告:
56
+
57
+ ```bash
58
+ ctx usage report --days 30
59
+ ctx usage report --month 2026-08 --granularity week
60
+ ctx usage report --since 2026-08-01T00:00:00.000Z --until 2026-09-01T00:00:00.000Z --baseline-tokens 12000 --baseline-source manual
61
+ ctx usage report --format ascii
62
+ ```
63
+
64
+ JSON 是默认输出;`--format ascii` 仅用于终端展示。报告分别统计 v1 文件资产和 v2 journal 事件,展示 Knowledge、Deadend、Note、Context、Snapshot、显式 reuse、操作趋势与 estimated token 投入。`record.used` 是唯一的 reuse 信号,候选项命中、compile 选择和普通检索都不算复用。
65
+
66
+ `--baseline-tokens` 必须是显式对照输入。未提供 baseline 时 savings、ROI 和 payback 为 `null` 或 `no_baseline`;字符换算不代表 Provider 真实 token,也不提供默认单价。报告失败时应使用 `ctx doctor` 与 `ctx journal verify` 进一步诊断,而不是把 report 当作数据修复工具。
67
+
49
68
  ### 初始化
50
69
 
51
70
  ```bash
@@ -103,7 +122,7 @@ ctx resume --path path/to/project --max-chars 2000
103
122
 
104
123
  ## MCP 与 Claude Code 集成
105
124
 
106
- `ctx agent serve` 提供 MCP stdio server,注册以下工具:`context_orient`、`context_expand`、`context_compile`、`context_resume`、`context_note_list`、`context_note_add` 和 `context_checkpoint`。标准输入和输出都是 MCP 协议,不能输出提示、日志或交互文本;调用错误是单次工具错误,不应让 server 退出。
125
+ `ctx agent serve` 提供 MCP stdio server,注册以下工具:`context_orient`、`context_expand`、`context_compile`、`context_resume`、`context_note_list`、`context_note_add`、`context_usage_report` 和 `context_checkpoint`。标准输入和输出都是 MCP 协议,不能输出提示、日志或交互文本;调用错误是单次工具错误,不应让 server 退出。
107
126
 
108
127
  读取工作流通常是 `context_orient → context_compile → context_expand`。`context_compile` 与 `context_expand` 继续保持有界、确定性和不读取文件正文的边界;`context_resume` 复用 `loadContext`,可能更新 `lastUsedAt`。`context_note_list` 是只读的,`context_note_add` 和 `context_checkpoint` 是显式写入操作,分别新增 Note 或保存 Snapshot;它们不会自动记录 feedback、Knowledge、Deadend 或重复 checkpoint。Knowledge/Deadend 的完整记录和治理仍使用 CLI/Agent API。
109
128
 
@@ -341,7 +360,8 @@ ctx learn "订单取消后不能再次进入支付中状态"
341
360
  [ ] .contextignored 排除敏感和越界路径
342
361
  [ ] Knowledge candidate → verified
343
362
  [ ] Deadend candidate → verified
344
- [ ] MCP context_orient/context_expand/context_compile/context_resume/context_note_list/context_note_add/context_checkpoint 可调用,且单次错误不会终止 server
363
+ [ ] MCP context_orient/context_expand/context_compile/context_resume/context_note_list/context_note_add/context_usage_report/context_checkpoint 可调用,且单次错误不会终止 server
364
+ [ ] ctx usage report 的 JSON/ASCII 输出、baseline 边界和只读性符合预期
345
365
  [ ] MCP 只读工具不写入 Context;Note add 和 checkpoint 仅在显式调用时写入
346
366
  [ ] Claude 集成预览不写文件,--apply 幂等且拒绝冲突
347
367
  [ ] Hook 失败时 fail open,且不会自动 checkpoint