fluffy-context 0.1.0 → 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fluffy_CX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Context Runtime
2
2
 
3
- Context Runtime 是一个面向 AI 编程会话的本地上下文运行时 CLI。它把一次任务的进度、决策、待办、风险、知识和已验证死路保存为可恢复的结构化状态,帮助 Agent 在新会话中快速完成交接,而不是重新阅读大量项目内容。
3
+ Context Runtime 是一个面向 AI 编程会话的本地上下文运行时 CLI。0.2.0 在可靠 Context 闭环之上增加了无模型、确定性的 Agent API 和 Knowledge Discovery,让 Agent 可以先发现已验证的项目共识,再保存和恢复工作状态,而不是每次重新阅读大量项目内容。
4
4
 
5
5
  它更接近“Context 的 Git”,而不是代码备份工具:Git 仍然负责代码和真实文件变更,Context Runtime 负责 AI 工作状态的版本化保存与恢复。
6
6
 
@@ -48,7 +48,8 @@ ctx init
48
48
  ├── contexts/
49
49
  ├── locks/
50
50
  ├── knowledge.json # 首次使用 learn 后创建
51
- └── deadends.json # 首次使用 deadend 后创建
51
+ ├── deadends.json # 首次使用 deadend 后创建
52
+ └── notes.json # 首次使用 note add 后创建
52
53
  .contextignored
53
54
  ```
54
55
 
@@ -77,8 +78,28 @@ ctx resume
77
78
  ctx resume --max-chars 2000
78
79
  ```
79
80
 
81
+ Agent 集成可以直接调用 `dist/src/agent/api.js` 中的 `saveContext`、`loadContext` 和 `searchContext`,不需要读取 `.context` 文件。推荐工作流是:先 `loadContext` 或 `ctx resume`,再用 `ctx knowledge discover` 或 `searchContext` 查找已验证共识,开发过程中记录 Note,阶段完成后 checkpoint,最后用 `learn` 和 `verify` 沉淀经过确认的知识。`loadContext` 默认不返回完整 details;候选 Knowledge、未验证 Deadend、模型推理、向量检索和远端服务不属于 0.2.0 默认能力。
82
+
83
+ 查看版本:
84
+
85
+ ```bash
86
+ ctx --version
87
+ ```
88
+
80
89
  ## 常用命令
81
90
 
91
+ 所有一级命令都支持独立帮助,帮助输出为面向终端和 Agent 的纯文本,不会读取或修改项目状态:
92
+
93
+ ```bash
94
+ ctx init --help
95
+ ctx checkpoint --help
96
+ ctx resume --help
97
+ ctx knowledge verify --help
98
+ ctx deadend verify --help
99
+ ```
100
+
101
+ 业务命令仍然默认输出 JSON;未知选项、缺少选项值和无效位置参数会以非零退出码报告。
102
+
82
103
  ### `ctx init`
83
104
 
84
105
  初始化项目的 Context Runtime 存储布局。重复执行是幂等的,不会覆盖已有 Context 或项目忽略规则。
@@ -106,7 +127,9 @@ ctx checkpoint \
106
127
  --files "src/payment/webhook.ts"
107
128
  ```
108
129
 
109
- 列表参数使用逗号分隔。`--context` 省略时会创建新的 Context。默认情况下,实际保存之间至少间隔 10 秒;短时间内有变化的保存会返回 `rate_limited`,没有变化则返回 `no_change`。
130
+ 列表参数使用逗号分隔。`--context` 省略时会创建新的 Context。默认情况下,实际保存之间至少间隔 10 秒;短时间内有变化的保存会返回 `rate_limited`,没有变化则返回 `no_change`。使用 `--absorb-notes` 时,只有真正保存了新 Snapshot 才会将当前 Context 下尚未吸收的 Note 标记为已吸收;`no_change` 和 `rate_limited` 不会改变 Note。
131
+
132
+ Checkpoint 会在写入前规范化新值:文本首尾空白会被移除,空白 `lastError` 会变成 `null`,列表中的空项会被移除。未提供字段会沿用已有值;显式传入空字符串或空列表会清除对应值。历史 Snapshot 不会被自动改写;`ctx doctor` 只负责报告历史数据损坏。
110
133
 
111
134
  ### `ctx resume`
112
135
 
@@ -146,16 +169,44 @@ ctx learn "订单取消后不能再次进入支付中状态" \
146
169
  --evidence "src/order/state-machine.ts,订单服务接口约束"
147
170
  ```
148
171
 
149
- 查看和确认知识:
172
+ 查看、发现和确认知识:
150
173
 
151
174
  ```bash
152
175
  ctx knowledge
153
176
  ctx knowledge --all
177
+ ctx knowledge discover "投保人认证"
178
+ ctx knowledge discover "identity verification" --scope project --limit 10 --max-chars 4000
154
179
  ctx knowledge verify <knowledge-id>
155
180
  ```
156
181
 
182
+ `discover` 默认只匹配已验证 Knowledge,返回命中的字段、匹配原因、来源 Context/Snapshot 和 supporting evidence。候选项不会参与普通发现;需要审查候选或其它状态时使用 `--all` 或 `--status candidate,verified`。匹配采用确定性的规范化文本和业务别名规则,不依赖模型或向量数据库。
183
+
157
184
  不提供 `--context` 时,知识仍可以记录为项目级候选知识;如果提供来源,则对应 Context 和 Snapshot 必须存在。
158
185
 
186
+ ### `ctx note`
187
+
188
+ `ctx note` 是 Agent 在开发过程中记录短期问题、观察、行动和决策的低成本入口。它不会创建 Context Snapshot,记录会以追加方式保留,后续可由 checkpoint 显式吸收。
189
+
190
+ ```bash
191
+ ctx note add "第三方人脸识别测试凭据缺失" \
192
+ --kind problem \
193
+ --context <context-id>
194
+ ctx note add "保留原始请求体后验签恢复" \
195
+ --kind decision \
196
+ --status resolved \
197
+ --context <context-id>
198
+ ctx note list --context <context-id> --open
199
+ ```
200
+
201
+ ### `ctx activity`
202
+
203
+ `ctx activity` 面向人类查看 Agent 的开发活动,返回 Note 和已保存 Snapshot 的时间线。它是只读查询,不会创建额外事件日志。
204
+
205
+ ```bash
206
+ ctx activity --context <context-id> --since 2026-08-21T00:00:00.000Z
207
+ ctx activity --open --limit 20
208
+ ```
209
+
159
210
  ### `ctx deadend`
160
211
 
161
212
  记录一条已尝试但不可行的路径。Deadend 默认是 `candidate`,不会默认注入恢复摘要。
@@ -0,0 +1,133 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { resolveProjectRoot } from '../project/project-resolver.js';
3
+ import { contextsRoot, contextMetadataPath } from '../storage/layout.js';
4
+ import { isRecord, readJson } from '../storage/json-store.js';
5
+ import { checkpoint, rebuildSnapshot, resume } from '../runtime/runtime.js';
6
+ import { discoverDeadends, discoverKnowledge, listDeadends } from '../runtime/knowledge.js';
7
+ function normalized(value) {
8
+ return value.trim().toLocaleLowerCase();
9
+ }
10
+ function terms(query) {
11
+ return [...new Set(normalized(query).split(/\s+/).filter(Boolean))];
12
+ }
13
+ function limited(value, budget) {
14
+ if (budget.remaining <= 0)
15
+ return '';
16
+ const result = value.length <= budget.remaining ? value : `${value.slice(0, Math.max(0, budget.remaining - 1))}…`;
17
+ budget.remaining -= result.length;
18
+ return result;
19
+ }
20
+ function limitedList(values, budget) {
21
+ return values.flatMap((value) => {
22
+ if (budget.remaining <= 0)
23
+ return [];
24
+ const result = limited(value, budget);
25
+ return result ? [result] : [];
26
+ });
27
+ }
28
+ function summary(content, maxChars, branchOrCommitDrift = false) {
29
+ const budget = { remaining: Math.max(0, maxChars) };
30
+ return {
31
+ progressSummary: limited(content.progressSummary, budget),
32
+ lastError: content.lastError === null ? null : limited(content.lastError, budget),
33
+ completed: limitedList(content.completed, budget),
34
+ pendingTasks: limitedList(content.pendingTasks, budget),
35
+ decisions: limitedList(content.decisions, budget),
36
+ risks: limitedList(content.risks, budget),
37
+ relatedFiles: limitedList(content.relatedFiles, budget),
38
+ branchOrCommitDrift,
39
+ };
40
+ }
41
+ function isContextMetadata(value) {
42
+ return isRecord(value)
43
+ && value.schemaVersion === 1
44
+ && typeof value.id === 'string'
45
+ && typeof value.title === 'string'
46
+ && typeof value.projectRoot === 'string'
47
+ && (typeof value.currentSnapshotId === 'string' || value.currentSnapshotId === null)
48
+ && ['draft', 'active', 'stable', 'archived', 'deleted'].includes(value.status);
49
+ }
50
+ function matchedContext(context, content, queryTerms) {
51
+ const values = {
52
+ title: context.title,
53
+ progressSummary: content.progressSummary,
54
+ lastError: content.lastError ?? '',
55
+ completed: content.completed.join(' '),
56
+ pendingTasks: content.pendingTasks.join(' '),
57
+ decisions: content.decisions.join(' '),
58
+ risks: content.risks.join(' '),
59
+ relatedFiles: content.relatedFiles.join(' '),
60
+ };
61
+ const matchedFields = new Set();
62
+ const matchedTerms = new Set();
63
+ let score = 0;
64
+ for (const term of queryTerms) {
65
+ for (const [field, value] of Object.entries(values)) {
66
+ if (normalized(value).includes(term)) {
67
+ matchedFields.add(field);
68
+ matchedTerms.add(term);
69
+ score += field === 'title' ? 5 : 3;
70
+ }
71
+ }
72
+ }
73
+ return { matchedFields: [...matchedFields], matchedTerms: [...matchedTerms], score };
74
+ }
75
+ export async function saveContext(startPath, input, options = {}) {
76
+ const projectRoot = await resolveProjectRoot(startPath);
77
+ return { projectRoot, result: await checkpoint(projectRoot, input, options) };
78
+ }
79
+ export async function loadContext(startPath, options = {}) {
80
+ const projectRoot = await resolveProjectRoot(startPath);
81
+ const result = await resume(projectRoot, options.contextId, options.maxChars ?? 4000);
82
+ const knowledge = options.query === undefined ? null : await discoverKnowledge(projectRoot, options.query, { scope: options.scope, maxChars: options.maxChars ?? 4000 });
83
+ const verifiedDeadends = await listDeadends(projectRoot);
84
+ return {
85
+ projectRoot,
86
+ context: result.context,
87
+ snapshot: result.snapshot,
88
+ resumeSummary: result.resumeSummary,
89
+ ...(options.includeDetails ? { details: result.details } : {}),
90
+ knowledge,
91
+ verifiedDeadendIds: verifiedDeadends.map((item) => item.deadendId),
92
+ };
93
+ }
94
+ export async function searchContext(startPath, query, options = {}) {
95
+ const projectRoot = await resolveProjectRoot(startPath);
96
+ const cleanQuery = query.trim();
97
+ if (!cleanQuery)
98
+ throw new Error('context search query must not be empty');
99
+ const queryTerms = terms(cleanQuery);
100
+ const contextIds = options.contextId ? [options.contextId] : await readdir(contextsRoot(projectRoot));
101
+ const matches = [];
102
+ for (const contextId of contextIds) {
103
+ const context = await readJson(contextMetadataPath(projectRoot, contextId), isContextMetadata);
104
+ if (!['active', 'stable'].includes(context.status) || !context.currentSnapshotId)
105
+ continue;
106
+ const content = await rebuildSnapshot(projectRoot, context.id, context.currentSnapshotId);
107
+ const match = matchedContext(context, content, queryTerms);
108
+ if (match.matchedTerms.length === 0)
109
+ continue;
110
+ matches.push({
111
+ context,
112
+ snapshotId: context.currentSnapshotId,
113
+ score: match.score,
114
+ matchedTerms: match.matchedTerms,
115
+ matchedFields: match.matchedFields,
116
+ summary: summary(content, options.maxChars ?? 4000),
117
+ });
118
+ }
119
+ matches.sort((left, right) => right.score - left.score || right.context.updatedAt.localeCompare(left.context.updatedAt) || left.context.id.localeCompare(right.context.id));
120
+ const limit = options.limit ?? 10;
121
+ const selected = matches.slice(0, limit);
122
+ const knowledge = await discoverKnowledge(projectRoot, cleanQuery, { scope: options.scope, maxChars: options.maxChars ?? 4000 });
123
+ const deadends = await discoverDeadends(projectRoot, cleanQuery, { scope: options.scope, maxChars: options.maxChars ?? 4000 });
124
+ return {
125
+ projectRoot,
126
+ query: cleanQuery,
127
+ total: matches.length,
128
+ truncated: matches.length > selected.length,
129
+ hits: selected,
130
+ knowledge,
131
+ verifiedDeadendIds: deadends.hits.map((hit) => hit.deadend.deadendId),
132
+ };
133
+ }
@@ -0,0 +1 @@
1
+ export * from './api.js';
@@ -2,31 +2,27 @@
2
2
  import { checkpoint, resume } from '../runtime/runtime.js';
3
3
  import { doctor, status } from '../runtime/diagnostics.js';
4
4
  import { initProject } from '../runtime/init.js';
5
- import { learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend } from '../runtime/knowledge.js';
5
+ import { discoverKnowledge, learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend } from '../runtime/knowledge.js';
6
+ import { addNote, listActivity, listNotes } from '../runtime/notes.js';
7
+ import { VERSION } from '../version.js';
6
8
  function option(args, name) {
7
9
  const index = args.indexOf(name);
8
10
  if (index < 0)
9
11
  return undefined;
10
12
  const value = args[index + 1];
11
- if (!value || value.startsWith('--'))
13
+ if (value === undefined || value.startsWith('--'))
12
14
  throw new Error(`missing value for ${name}`);
13
15
  return value;
14
16
  }
15
- function required(args, name) {
16
- const value = option(args, name);
17
- if (!value)
18
- throw new Error(`missing required option ${name}`);
19
- return value;
20
- }
21
17
  function listOption(args, name) {
22
18
  const value = option(args, name);
23
- return value ? value.split(',').map((item) => item.trim()).filter(Boolean) : undefined;
19
+ return value === undefined ? undefined : value.split(',').map((item) => item.trim()).filter(Boolean);
24
20
  }
25
- function positionals(args, options) {
21
+ function positionals(args, valueOptions) {
26
22
  const values = [];
27
23
  for (let index = 0; index < args.length; index += 1) {
28
- if (args[index].startsWith('--') || args[index] === '-m') {
29
- if (options.includes(args[index]))
24
+ if (args[index].startsWith('-')) {
25
+ if (valueOptions.includes(args[index]))
30
26
  index += 1;
31
27
  continue;
32
28
  }
@@ -34,15 +30,19 @@ function positionals(args, options) {
34
30
  }
35
31
  return values;
36
32
  }
37
- function validateOptions(args, allowed) {
33
+ function validateOptions(args, allowed, valueOptions = []) {
38
34
  for (let index = 0; index < args.length; index += 1) {
39
35
  const argument = args[index];
40
36
  if (!argument.startsWith('-'))
41
37
  continue;
42
38
  if (!allowed.includes(argument))
43
39
  throw new Error(`unknown option ${argument}`);
44
- if (argument !== '--all' && argument !== '--help' && argument !== '--version')
40
+ if (valueOptions.includes(argument)) {
41
+ const value = args[index + 1];
42
+ if (value === undefined || value.startsWith('-'))
43
+ throw new Error(`missing value for ${argument}`);
45
44
  index += 1;
45
+ }
46
46
  }
47
47
  }
48
48
  function numericOption(args, name, defaultValue) {
@@ -54,33 +54,203 @@ function numericOption(args, name, defaultValue) {
54
54
  throw new Error(`${name} must be a non-negative integer`);
55
55
  return parsed;
56
56
  }
57
+ function enumOption(args, name, values) {
58
+ const value = option(args, name);
59
+ if (value === undefined)
60
+ return undefined;
61
+ if (!values.includes(value))
62
+ throw new Error(`${name} must be one of: ${values.join(', ')}`);
63
+ return value;
64
+ }
65
+ const HELP = {
66
+ init: `usage: ctx init [path] [--path <path>]
67
+
68
+ Initialize the local .context runtime layout. Re-running init is safe.
69
+
70
+ Options:
71
+ --path <path> Project path`,
72
+ checkpoint: `usage: ctx checkpoint [options]
73
+
74
+ Save structured work state as a baseline or incremental snapshot.
75
+
76
+ Options:
77
+ --path <path> Project path
78
+ --context <id> Existing context ID
79
+ --title <text> Context title
80
+ --progress <text> Current progress summary
81
+ --last-error <text> Last error (blank clears it)
82
+ --completed <items> Comma-separated completed items
83
+ --pending <items> Comma-separated pending tasks
84
+ --decisions <items> Comma-separated decisions
85
+ --risks <items> Comma-separated risks
86
+ --files <paths> Comma-separated related paths
87
+ --absorb-notes Mark open notes for this context as absorbed`,
88
+ resume: `usage: ctx resume [options]
89
+
90
+ Resume the most relevant active or stable context.
91
+
92
+ Options:
93
+ --path <path> Project path
94
+ --context <id> Context ID
95
+ --max-chars <number> Maximum summary characters`,
96
+ status: `usage: ctx status [--path <path>]
97
+
98
+ Show the initialized project and context index.`,
99
+ doctor: `usage: ctx doctor [--path <path>]
100
+
101
+ Check runtime files and snapshot integrity without modifying them.`,
102
+ learn: `usage: ctx learn <statement> [options]
103
+
104
+ Record a candidate project knowledge item.
105
+
106
+ Options:
107
+ --path <path> Project path
108
+ --kind <kind> Knowledge kind
109
+ --scope <scope> Knowledge scope
110
+ --context <id> Source context ID
111
+ --snapshot <id> Source snapshot ID
112
+ --evidence <items> Comma-separated evidence references`,
113
+ knowledge: `usage: ctx knowledge [--all] [--path <path>]
114
+ ctx knowledge verify <knowledge-id> [--path <path>]
115
+ ctx knowledge discover <query> [options]
116
+
117
+ List candidate/verified knowledge, verify one item, or discover matching knowledge.
118
+
119
+ Options:
120
+ --all Include unverified items
121
+ --path <path> Project path`,
122
+ 'knowledge verify': `usage: ctx knowledge verify <knowledge-id> [--path <path>]
123
+
124
+ Mark a knowledge item as verified.`,
125
+ 'knowledge discover': `usage: ctx knowledge discover <query> [options]
126
+
127
+ Find matching project knowledge using deterministic lexical and alias rules.
128
+
129
+ Options:
130
+ --path <path> Project path
131
+ --scope <scope> Exact knowledge scope
132
+ --status <statuses> Comma-separated candidate/verified/deprecated/rejected
133
+ --all Include all knowledge statuses
134
+ --limit <number> Maximum matches (default: 10)
135
+ --max-chars <number> Maximum text characters per match`,
136
+ deadend: `usage: ctx deadend [attempt] [options]
137
+ ctx deadend verify <deadend-id> [--path <path>]
138
+
139
+ Record or verify a candidate deadend.
140
+
141
+ Options:
142
+ --path <path> Project path
143
+ --attempt <text> Attempt description
144
+ --reason <text> Why it failed
145
+ -m <text> Alias for --reason
146
+ --scope <scope> Deadend scope
147
+ --context <id> Source context ID
148
+ --snapshot <id> Source snapshot ID
149
+ --evidence <items> Comma-separated evidence references`,
150
+ 'deadend verify': `usage: ctx deadend verify <deadend-id> [--path <path>]
151
+
152
+ Mark a deadend as verified.`,
153
+ deadends: `usage: ctx deadends [--all] [--path <path>]
154
+
155
+ List recorded deadends.`,
156
+ note: `usage: ctx note add <message> [options]
157
+ ctx note list [options]
158
+
159
+ Agent-facing short-term notes for problems, actions, observations, and decisions.
160
+
161
+ Run \"ctx note add --help\" or \"ctx note list --help\" for details.`,
162
+ 'note add': `usage: ctx note add <message> [options]
163
+
164
+ Append a short note without creating a Context Snapshot.
165
+
166
+ Options:
167
+ --path <path> Project path
168
+ --kind <kind> problem|action|observation|decision
169
+ --context <id> Context ID
170
+ --snapshot <id> Snapshot ID
171
+ --files <paths> Comma-separated related paths
172
+ --actor <actor> agent|human
173
+ --status <status> open|resolved`,
174
+ 'note list': `usage: ctx note list [options]
175
+
176
+ List recent notes for an Agent or integration.
177
+
178
+ Options:
179
+ --path <path> Project path
180
+ --context <id> Context ID
181
+ --open Only unresolved and unabsorbed notes
182
+ --limit <number> Maximum notes (default: 50)
183
+ --max-chars <number> Maximum message characters`,
184
+ activity: `usage: ctx activity [options]
185
+
186
+ Show a Human-oriented timeline of notes and saved checkpoints.
187
+
188
+ Options:
189
+ --path <path> Project path
190
+ --context <id> Context ID
191
+ --since <ISO> Only activity at or after this time
192
+ --open Only open notes
193
+ --limit <number> Maximum items (default: 50)
194
+ --max-chars <number> Maximum message characters`,
195
+ };
57
196
  function usage() {
58
- return 'usage: ctx init|checkpoint|resume|status|doctor|learn|knowledge|deadend|deadends [options]';
197
+ return `usage: ctx init|checkpoint|resume|status|doctor|learn|knowledge|deadend|deadends|note|activity [options]
198
+
199
+ Run \"ctx <command> --help\" for command details.`;
59
200
  }
60
201
  function print(value) {
61
202
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
62
203
  }
204
+ function printHelp(args) {
205
+ const command = args[0];
206
+ if (!command) {
207
+ process.stdout.write(`${usage()}\n`);
208
+ return true;
209
+ }
210
+ const key = args[1] === 'verify' || args[1] === 'add' || args[1] === 'list' || args[1] === 'discover' ? `${command} ${args[1]}` : command;
211
+ const help = HELP[key];
212
+ if (!help)
213
+ return false;
214
+ if (args.includes('--help')) {
215
+ process.stdout.write(`${help}\n`);
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+ function validatePositionals(values, max, usageText) {
221
+ if (values.length > max)
222
+ throw new Error(usageText);
223
+ }
63
224
  async function run(args) {
64
225
  if (args.includes('--version')) {
65
- print('0.1.0');
226
+ print(VERSION);
66
227
  return;
67
228
  }
68
- if (args.length === 0 || args.includes('--help')) {
69
- process.stdout.write(`${usage()}\n`);
229
+ if (args.length === 0 || args[0] === '--help') {
230
+ printHelp([]);
70
231
  return;
71
232
  }
233
+ if (args.includes('--help') && printHelp(args))
234
+ return;
72
235
  const command = args[0];
73
236
  const target = option(args, '--path');
74
237
  switch (command) {
75
- case 'init':
76
- print(await initProject(target ?? args[1]));
238
+ case 'init': {
239
+ validateOptions(args.slice(1), ['--path'], ['--path']);
240
+ const values = positionals(args.slice(1), ['--path']);
241
+ validatePositionals(values, 1, HELP.init);
242
+ print(await initProject(target ?? values[0]));
77
243
  return;
244
+ }
78
245
  case 'checkpoint': {
246
+ validateOptions(args.slice(1), ['--path', '--context', '--title', '--progress', '--last-error', '--completed', '--pending', '--decisions', '--risks', '--files', '--absorb-notes'], ['--path', '--context', '--title', '--progress', '--last-error', '--completed', '--pending', '--decisions', '--risks', '--files']);
247
+ validatePositionals(positionals(args.slice(1), ['--path', '--context', '--title', '--progress', '--last-error', '--completed', '--pending', '--decisions', '--risks', '--files']), 0, HELP.checkpoint);
79
248
  const input = {
80
249
  contextId: option(args, '--context'),
250
+ absorbNotes: args.includes('--absorb-notes'),
81
251
  title: option(args, '--title'),
82
252
  progressSummary: option(args, '--progress'),
83
- lastError: option(args, '--last-error') ?? null,
253
+ lastError: option(args, '--last-error'),
84
254
  completed: listOption(args, '--completed'),
85
255
  pendingTasks: listOption(args, '--pending'),
86
256
  decisions: listOption(args, '--decisions'),
@@ -91,18 +261,28 @@ async function run(args) {
91
261
  return;
92
262
  }
93
263
  case 'resume':
94
- validateOptions(args.slice(1), ['--path', '--context', '--max-chars']);
264
+ validateOptions(args.slice(1), ['--path', '--context', '--max-chars'], ['--path', '--context', '--max-chars']);
265
+ validatePositionals(positionals(args.slice(1), ['--path', '--context', '--max-chars']), 0, HELP.resume);
95
266
  print(await resume(target, option(args, '--context'), numericOption(args, '--max-chars', 4000)));
96
267
  return;
97
268
  case 'status':
269
+ validateOptions(args.slice(1), ['--path'], ['--path']);
270
+ validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.status);
98
271
  print(await status(target));
99
272
  return;
100
273
  case 'doctor':
274
+ validateOptions(args.slice(1), ['--path'], ['--path']);
275
+ validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.doctor);
101
276
  print(await doctor(target));
102
277
  return;
103
278
  case 'learn': {
104
- const statement = positionals(args.slice(1), ['--path', '--scope', '--context', '--snapshot', '--evidence']);
279
+ const valueOptions = ['--path', '--kind', '--scope', '--context', '--snapshot', '--evidence'];
280
+ validateOptions(args.slice(1), valueOptions, valueOptions);
281
+ const statement = positionals(args.slice(1), valueOptions);
282
+ if (statement.length === 0)
283
+ throw new Error(HELP.learn);
105
284
  const input = {
285
+ kind: option(args, '--kind'),
106
286
  scope: option(args, '--scope'),
107
287
  sourceContextId: option(args, '--context'),
108
288
  sourceSnapshotId: option(args, '--snapshot'),
@@ -111,26 +291,104 @@ async function run(args) {
111
291
  print(await learnKnowledge(target, statement.join(' '), input));
112
292
  return;
113
293
  }
114
- case 'knowledge':
115
- if (args[1] === 'verify')
116
- print(await verifyKnowledge(target, args[2]));
117
- else
294
+ case 'note': {
295
+ if (args[1] === 'add') {
296
+ const valueOptions = ['--path', '--kind', '--context', '--snapshot', '--files', '--actor', '--status'];
297
+ validateOptions(args.slice(2), valueOptions, valueOptions);
298
+ const values = positionals(args.slice(2), valueOptions);
299
+ if (values.length === 0)
300
+ throw new Error(HELP['note add']);
301
+ const input = {
302
+ kind: enumOption(args, '--kind', ['problem', 'action', 'observation', 'decision']),
303
+ actor: enumOption(args, '--actor', ['agent', 'human']),
304
+ contextId: option(args, '--context'),
305
+ snapshotId: option(args, '--snapshot'),
306
+ relatedFiles: listOption(args, '--files'),
307
+ status: enumOption(args, '--status', ['open', 'resolved']),
308
+ };
309
+ print(await addNote(target, values.join(' '), input));
310
+ }
311
+ else if (args[1] === 'list') {
312
+ const valueOptions = ['--path', '--context', '--limit', '--max-chars'];
313
+ validateOptions(args.slice(2), [...valueOptions, '--open'], valueOptions);
314
+ validatePositionals(positionals(args.slice(2), valueOptions), 0, HELP['note list']);
315
+ print(await listNotes(target, { contextId: option(args, '--context'), openOnly: args.includes('--open'), limit: numericOption(args, '--limit', 50), maxChars: numericOption(args, '--max-chars', 500) }));
316
+ }
317
+ else {
318
+ throw new Error(HELP.note);
319
+ }
320
+ return;
321
+ }
322
+ case 'activity': {
323
+ const valueOptions = ['--path', '--context', '--since', '--limit', '--max-chars'];
324
+ validateOptions(args.slice(1), [...valueOptions, '--open'], valueOptions);
325
+ validatePositionals(positionals(args.slice(1), valueOptions), 0, HELP.activity);
326
+ const query = {
327
+ contextId: option(args, '--context'),
328
+ since: option(args, '--since'),
329
+ openOnly: args.includes('--open'),
330
+ limit: numericOption(args, '--limit', 50),
331
+ maxChars: numericOption(args, '--max-chars', 500),
332
+ };
333
+ print(await listActivity(target, query));
334
+ return;
335
+ }
336
+ case 'knowledge': {
337
+ if (args[1] === 'discover') {
338
+ const valueOptions = ['--path', '--scope', '--status', '--limit', '--max-chars'];
339
+ validateOptions(args.slice(2), [...valueOptions, '--all'], valueOptions);
340
+ const query = positionals(args.slice(2), valueOptions);
341
+ if (query.length === 0)
342
+ throw new Error(HELP['knowledge discover']);
343
+ const statuses = args.includes('--all') ? ['candidate', 'verified', 'deprecated', 'rejected'] : (listOption(args, '--status') ?? ['verified']);
344
+ if (statuses.some((status) => !['candidate', 'verified', 'deprecated', 'rejected'].includes(status)))
345
+ throw new Error('--status contains an invalid knowledge status');
346
+ print(await discoverKnowledge(target, query.join(' '), {
347
+ scope: option(args, '--scope'),
348
+ statuses: statuses,
349
+ limit: numericOption(args, '--limit', 10),
350
+ maxChars: numericOption(args, '--max-chars', 4000),
351
+ }));
352
+ }
353
+ else if (args[1] === 'verify') {
354
+ validateOptions(args.slice(2), ['--path'], ['--path']);
355
+ const values = positionals(args.slice(2), ['--path']);
356
+ if (values.length !== 1)
357
+ throw new Error(HELP['knowledge verify']);
358
+ print(await verifyKnowledge(target, values[0]));
359
+ }
360
+ else {
361
+ validateOptions(args.slice(1), ['--all', '--path'], ['--path']);
362
+ validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.knowledge);
118
363
  print(await listKnowledge(target, args.includes('--all')));
364
+ }
119
365
  return;
366
+ }
120
367
  case 'deadend':
121
- if (args[1] === 'verify')
122
- print(await verifyDeadend(target, args[2]));
368
+ if (args[1] === 'verify') {
369
+ validateOptions(args.slice(2), ['--path'], ['--path']);
370
+ const values = positionals(args.slice(2), ['--path']);
371
+ if (values.length !== 1)
372
+ throw new Error(HELP['deadend verify']);
373
+ print(await verifyDeadend(target, values[0]));
374
+ }
123
375
  else {
376
+ const valueOptions = ['--path', '--attempt', '--reason', '-m', '--scope', '--context', '--snapshot', '--evidence'];
377
+ validateOptions(args.slice(1), valueOptions, valueOptions);
378
+ const values = positionals(args.slice(1), valueOptions);
379
+ validatePositionals(values, 1, HELP.deadend);
124
380
  const input = {
125
381
  scope: option(args, '--scope'),
126
382
  sourceContextId: option(args, '--context'),
127
383
  sourceSnapshotId: option(args, '--snapshot'),
128
384
  evidence: listOption(args, '--evidence'),
129
385
  };
130
- print(await recordDeadend(target, option(args, '--attempt') ?? args[1], option(args, '--reason') ?? option(args, '-m') ?? '', input));
386
+ print(await recordDeadend(target, option(args, '--attempt') ?? values[0], option(args, '--reason') ?? option(args, '-m') ?? '', input));
131
387
  }
132
388
  return;
133
389
  case 'deadends':
390
+ validateOptions(args.slice(1), ['--all', '--path'], ['--path']);
391
+ validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.deadends);
134
392
  print(await listDeadends(target, args.includes('--all')));
135
393
  return;
136
394
  default:
@@ -1,8 +1,10 @@
1
1
  import { access, readdir } from 'node:fs/promises';
2
2
  import { resolveProjectRoot } from '../project/project-resolver.js';
3
3
  import { readJson } from '../storage/json-store.js';
4
- import { contextRoot, contextMetadataPath, contextsRoot, indexPath, manifestPath, snapshotPath } from '../storage/layout.js';
5
- import { rebuildIndex } from './runtime.js';
4
+ import { contextRoot, contextMetadataPath, contextsRoot, indexPath, manifestPath, notesPath, snapshotPath } from '../storage/layout.js';
5
+ import { rebuildIndex, rebuildSnapshot } from './runtime.js';
6
+ import { isValidNote } from './notes.js';
7
+ import { filterPaths } from '../capture/context-filter.js';
6
8
  export async function status(startPath) {
7
9
  const projectRoot = await resolveProjectRoot(startPath);
8
10
  try {
@@ -50,15 +52,42 @@ export async function doctor(startPath) {
50
52
  const entries = await readdir(contextsRoot(projectRoot));
51
53
  for (const contextId of entries) {
52
54
  const context = await readJson(contextMetadataPath(projectRoot, contextId));
53
- if (context.id !== contextId || context.schemaVersion !== 1)
55
+ if (context.id !== contextId || context.schemaVersion !== 1 || typeof context.title !== 'string' || typeof context.projectRoot !== 'string' || typeof context.updatedAt !== 'string') {
54
56
  throw new Error(`invalid context ${contextId}`);
55
- if (context.currentSnapshotId)
57
+ }
58
+ if (context.currentSnapshotId) {
56
59
  await access(snapshotPath(projectRoot, contextId, context.currentSnapshotId));
60
+ await rebuildSnapshot(projectRoot, contextId, context.currentSnapshotId);
61
+ }
62
+ }
63
+ });
64
+ await check('notes', async () => {
65
+ try {
66
+ const notes = await readJson(notesPath(projectRoot));
67
+ if (notes.schemaVersion !== 1 || !Array.isArray(notes.items) || notes.items.some((note) => !isValidNote(note)))
68
+ throw new Error('invalid notes');
69
+ const ids = new Set(notes.items.map((note) => note.noteId));
70
+ if (ids.size !== notes.items.length)
71
+ throw new Error('duplicate note id');
72
+ const contextIds = new Set((await readdir(contextsRoot(projectRoot))).filter((entry) => entry.startsWith('ctx-')));
73
+ for (const note of notes.items) {
74
+ if (note.contextId && !contextIds.has(note.contextId))
75
+ throw new Error(`note ${note.noteId} references missing context`);
76
+ if (note.relatedFiles.length !== (await filterPaths(projectRoot, note.relatedFiles)).length)
77
+ throw new Error(`note ${note.noteId} contains filtered paths`);
78
+ if (note.snapshotId && note.contextId)
79
+ await access(snapshotPath(projectRoot, note.contextId, note.snapshotId));
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
84
+ return;
85
+ throw error;
57
86
  }
58
87
  });
59
88
  await check('index rebuild', async () => {
60
89
  const stored = await readJson(indexPath(projectRoot));
61
- const rebuilt = await rebuildIndex(projectRoot);
90
+ const rebuilt = await rebuildIndex(projectRoot, false);
62
91
  const rebuiltIds = new Set(rebuilt.contexts.map((entry) => entry.contextId));
63
92
  const storedIds = new Set(stored.contexts.map((entry) => entry.contextId));
64
93
  if (rebuiltIds.size !== rebuilt.contexts.length || storedIds.size !== stored.contexts.length)
@@ -1,30 +1,73 @@
1
1
  import crypto from 'node:crypto';
2
- import { access } from 'node:fs/promises';
3
2
  import { resolveProjectRoot } from '../project/project-resolver.js';
4
3
  import { atomicWriteJson } from '../storage/atomic-write.js';
5
4
  import { isRecord, readJson } from '../storage/json-store.js';
6
5
  import { withLock } from '../storage/lock.js';
7
6
  import { contextMetadataPath, deadendsPath, knowledgePath, locksDirectory, snapshotPath } from '../storage/layout.js';
7
+ import { rebuildSnapshot } from './runtime.js';
8
8
  function id(prefix) {
9
9
  return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
10
10
  }
11
11
  function isNotFound(error) {
12
12
  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
13
13
  }
14
+ function isKnowledge(value) {
15
+ return isRecord(value)
16
+ && typeof value.knowledgeId === 'string'
17
+ && typeof value.kind === 'string' && value.kind.trim().length > 0
18
+ && typeof value.statement === 'string' && value.statement.trim().length > 0
19
+ && typeof value.scope === 'string' && value.scope.trim().length > 0
20
+ && typeof value.confidence === 'number' && value.confidence >= 0 && value.confidence <= 1
21
+ && ['candidate', 'verified', 'deprecated', 'rejected'].includes(value.status)
22
+ && Array.isArray(value.sourceContextIds) && value.sourceContextIds.every((item) => typeof item === 'string')
23
+ && Array.isArray(value.sourceSnapshotIds) && value.sourceSnapshotIds.every((item) => typeof item === 'string')
24
+ && Array.isArray(value.supportingEvidence) && value.supportingEvidence.every((item) => typeof item === 'string')
25
+ && typeof value.createdAt === 'string'
26
+ && typeof value.updatedAt === 'string';
27
+ }
14
28
  function isKnowledgeFile(value) {
15
- return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
29
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.items) || !value.items.every(isKnowledge))
30
+ return false;
31
+ const ids = value.items.map((item) => item.knowledgeId);
32
+ return new Set(ids).size === ids.length;
33
+ }
34
+ function cleanList(values) {
35
+ return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
36
+ }
37
+ function normalized(value) {
38
+ return value.trim().toLocaleLowerCase();
39
+ }
40
+ function isDeadend(value) {
41
+ return isRecord(value)
42
+ && typeof value.deadendId === 'string'
43
+ && typeof value.attempt === 'string'
44
+ && typeof value.reason === 'string'
45
+ && Array.isArray(value.observedEvidence) && value.observedEvidence.every((item) => typeof item === 'string')
46
+ && typeof value.scope === 'string'
47
+ && ['candidate', 'verified', 'obsolete', 'rejected'].includes(value.status)
48
+ && (typeof value.sourceContextId === 'string' || value.sourceContextId === null)
49
+ && (typeof value.sourceSnapshotId === 'string' || value.sourceSnapshotId === null)
50
+ && typeof value.createdAt === 'string'
51
+ && typeof value.updatedAt === 'string';
16
52
  }
17
53
  function isDeadendFile(value) {
18
- return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
54
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.items) || !value.items.every(isDeadend))
55
+ return false;
56
+ const ids = value.items.map((item) => item.deadendId);
57
+ return new Set(ids).size === ids.length;
19
58
  }
20
59
  async function validateSource(projectRoot, contextId, snapshotId) {
21
60
  if (!contextId && snapshotId)
22
61
  throw new Error('source snapshot requires source context');
23
62
  if (!contextId)
24
63
  return;
25
- await access(contextMetadataPath(projectRoot, contextId));
26
- if (snapshotId)
27
- await access(snapshotPath(projectRoot, contextId, snapshotId));
64
+ const context = await readJson(contextMetadataPath(projectRoot, contextId), (value) => isRecord(value) && value.id === contextId && value.schemaVersion === 1);
65
+ if (snapshotId) {
66
+ const snapshot = await readJson(snapshotPath(projectRoot, contextId, snapshotId), (value) => isRecord(value) && value.snapshotId === snapshotId && value.contextId === contextId && value.schemaVersion === 1);
67
+ await rebuildSnapshot(projectRoot, contextId, snapshot.snapshotId);
68
+ }
69
+ if (context.projectRoot !== projectRoot)
70
+ throw new Error(`source context belongs to another project: ${contextId}`);
28
71
  }
29
72
  async function loadKnowledge(projectRoot) {
30
73
  try {
@@ -49,20 +92,29 @@ async function loadDeadends(projectRoot) {
49
92
  export async function learnKnowledge(startPath, statement, scopeOrInput = 'project') {
50
93
  const projectRoot = await resolveProjectRoot(startPath);
51
94
  const input = typeof scopeOrInput === 'string' ? { scope: scopeOrInput } : scopeOrInput;
95
+ const cleanStatement = statement.trim();
96
+ const cleanScope = (input.scope ?? 'project').trim();
97
+ const cleanKind = (input.kind ?? 'fact').trim();
98
+ if (!cleanStatement)
99
+ throw new Error('knowledge statement must not be empty');
100
+ if (!cleanScope)
101
+ throw new Error('knowledge scope must not be empty');
102
+ if (!cleanKind)
103
+ throw new Error('knowledge kind must not be empty');
52
104
  await validateSource(projectRoot, input.sourceContextId, input.sourceSnapshotId);
53
105
  return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
54
106
  const file = await loadKnowledge(projectRoot);
55
107
  const now = new Date().toISOString();
56
108
  const item = {
57
109
  knowledgeId: id('know'),
58
- kind: 'fact',
59
- statement,
60
- scope: input.scope ?? 'project',
110
+ kind: cleanKind,
111
+ statement: cleanStatement,
112
+ scope: cleanScope,
61
113
  confidence: 0.5,
62
114
  status: 'candidate',
63
- sourceContextIds: input.sourceContextId ? [input.sourceContextId] : [],
64
- sourceSnapshotIds: input.sourceSnapshotId ? [input.sourceSnapshotId] : [],
65
- supportingEvidence: input.evidence ?? [],
115
+ sourceContextIds: cleanList(input.sourceContextId ? [input.sourceContextId] : []),
116
+ sourceSnapshotIds: cleanList(input.sourceSnapshotId ? [input.sourceSnapshotId] : []),
117
+ supportingEvidence: cleanList(input.evidence),
66
118
  createdAt: now,
67
119
  updatedAt: now,
68
120
  };
@@ -75,6 +127,78 @@ export async function listKnowledge(startPath, includeUnverified = false) {
75
127
  const file = await loadKnowledge(projectRoot);
76
128
  return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
77
129
  }
130
+ const aliasGroups = [
131
+ ['投保人认证', '投保人身份认证', '身份核验', '实名认证', 'applicant authentication', 'identity verification'],
132
+ ['短信验证码', '验证码', 'sms verification', 'otp'],
133
+ ['证件ocr', '证件识别', 'identity ocr', 'document ocr'],
134
+ ['人脸识别', '人脸认证', 'face recognition', 'facial verification'],
135
+ ];
136
+ function queryTerms(query) {
137
+ const normalizedQuery = normalized(query);
138
+ const terms = new Set(normalizedQuery.split(/\s+/).filter(Boolean));
139
+ for (const group of aliasGroups) {
140
+ if (group.some((alias) => normalizedQuery.includes(normalized(alias)))) {
141
+ for (const alias of group)
142
+ terms.add(normalized(alias));
143
+ }
144
+ }
145
+ return [...terms];
146
+ }
147
+ function matchedFields(knowledge, terms) {
148
+ const values = { statement: normalized(knowledge.statement), kind: normalized(knowledge.kind), scope: normalized(knowledge.scope) };
149
+ const fields = new Set();
150
+ const matched = new Set();
151
+ let score = 0;
152
+ const normalizedQuery = terms.join(' ');
153
+ if (values.statement.includes(normalizedQuery))
154
+ score += 20;
155
+ for (const term of terms) {
156
+ let matchedTerm = false;
157
+ for (const [field, value] of Object.entries(values)) {
158
+ if (value.includes(term)) {
159
+ fields.add(field);
160
+ matchedTerm = true;
161
+ score += field === 'statement' ? 5 : field === 'scope' ? 4 : 3;
162
+ }
163
+ }
164
+ if (matchedTerm)
165
+ matched.add(term);
166
+ }
167
+ if (values.scope === normalizedQuery)
168
+ score += 10;
169
+ return { fields: [...fields], matchedTerms: [...matched], score };
170
+ }
171
+ function limitedKnowledge(knowledge, maxChars) {
172
+ const statement = knowledge.statement.length <= maxChars ? knowledge.statement : `${knowledge.statement.slice(0, Math.max(0, maxChars - 1))}…`;
173
+ return { ...knowledge, statement, supportingEvidence: knowledge.supportingEvidence.map((item) => item.length <= maxChars ? item : `${item.slice(0, Math.max(0, maxChars - 1))}…`) };
174
+ }
175
+ export async function discoverKnowledge(startPath, query, options = {}) {
176
+ const projectRoot = await resolveProjectRoot(startPath);
177
+ const cleanQuery = query.trim();
178
+ if (!cleanQuery)
179
+ throw new Error('knowledge discovery query must not be empty');
180
+ const file = await loadKnowledge(projectRoot);
181
+ const statuses = options.statuses ?? ['verified'];
182
+ const terms = queryTerms(cleanQuery);
183
+ const matches = file.items
184
+ .filter((item) => statuses.includes(item.status))
185
+ .filter((item) => !options.scope || normalized(item.scope) === normalized(options.scope))
186
+ .map((knowledge) => ({ knowledge, ...matchedFields(knowledge, terms) }))
187
+ .filter((item) => item.matchedTerms.length > 0)
188
+ .sort((left, right) => right.score - left.score || Number(right.knowledge.status === 'verified') - Number(left.knowledge.status === 'verified') || right.knowledge.confidence - left.knowledge.confidence || right.knowledge.updatedAt.localeCompare(left.knowledge.updatedAt) || left.knowledge.knowledgeId.localeCompare(right.knowledge.knowledgeId));
189
+ const duplicateMap = new Map();
190
+ for (const item of file.items) {
191
+ const key = `${normalized(item.kind)}|${normalized(item.scope)}|${normalized(item.statement)}`;
192
+ duplicateMap.set(key, [...(duplicateMap.get(key) ?? []), item.knowledgeId]);
193
+ }
194
+ const duplicateIds = [...duplicateMap.values()].filter((ids) => ids.length > 1);
195
+ const possibleConflicts = file.items.flatMap((left, index) => file.items.slice(index + 1).filter((right) => normalized(left.scope) === normalized(right.scope) && normalized(left.statement) !== normalized(right.statement) && queryTerms(left.statement).some((term) => normalized(right.statement).includes(term))).map((right) => ({ scope: left.scope, knowledgeIds: [left.knowledgeId, right.knowledgeId], statements: [left.statement, right.statement] })));
196
+ const limit = options.limit ?? 10;
197
+ const selected = matches.slice(0, limit);
198
+ const maxChars = options.maxChars ?? 4000;
199
+ const hits = selected.map((item) => ({ knowledge: limitedKnowledge(item.knowledge, maxChars), score: item.score, matchedTerms: item.matchedTerms, matchedFields: item.fields, why: item.fields.map((field) => `matched ${field}`) }));
200
+ return { query: cleanQuery, total: matches.length, truncated: matches.length > selected.length, hits, duplicateIds, possibleConflicts };
201
+ }
78
202
  export async function verifyKnowledge(startPath, knowledgeId) {
79
203
  const projectRoot = await resolveProjectRoot(startPath);
80
204
  return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
@@ -117,6 +241,49 @@ export async function listDeadends(startPath, includeUnverified = false) {
117
241
  const file = await loadDeadends(projectRoot);
118
242
  return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
119
243
  }
244
+ function limitedDeadend(deadend, maxChars) {
245
+ const limit = (value) => value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
246
+ return { ...deadend, attempt: limit(deadend.attempt), reason: limit(deadend.reason), observedEvidence: deadend.observedEvidence.map(limit) };
247
+ }
248
+ export async function discoverDeadends(startPath, query, options = {}) {
249
+ const projectRoot = await resolveProjectRoot(startPath);
250
+ const cleanQuery = query.trim();
251
+ if (!cleanQuery)
252
+ throw new Error('deadend discovery query must not be empty');
253
+ const terms = normalized(cleanQuery).split(/\s+/).filter(Boolean);
254
+ const statuses = options.statuses ?? ['verified'];
255
+ const file = await loadDeadends(projectRoot);
256
+ const matches = file.items
257
+ .filter((item) => statuses.includes(item.status))
258
+ .filter((item) => !options.scope || normalized(item.scope) === normalized(options.scope))
259
+ .map((deadend) => {
260
+ const values = { attempt: normalized(deadend.attempt), reason: normalized(deadend.reason), scope: normalized(deadend.scope) };
261
+ const fields = new Set();
262
+ const matchedTerms = new Set();
263
+ let score = 0;
264
+ for (const term of terms) {
265
+ for (const [field, value] of Object.entries(values)) {
266
+ if (value.includes(term)) {
267
+ fields.add(field);
268
+ matchedTerms.add(term);
269
+ score += field === 'attempt' ? 5 : field === 'reason' ? 4 : 3;
270
+ }
271
+ }
272
+ }
273
+ return { deadend, score, matchedTerms: [...matchedTerms], matchedFields: [...fields] };
274
+ })
275
+ .filter((item) => item.matchedTerms.length > 0)
276
+ .sort((left, right) => right.score - left.score || right.deadend.updatedAt.localeCompare(left.deadend.updatedAt) || left.deadend.deadendId.localeCompare(right.deadend.deadendId));
277
+ const limit = options.limit ?? 10;
278
+ const selected = matches.slice(0, limit);
279
+ const maxChars = options.maxChars ?? 4000;
280
+ return {
281
+ query: cleanQuery,
282
+ total: matches.length,
283
+ truncated: matches.length > selected.length,
284
+ hits: selected.map((item) => ({ ...item, deadend: limitedDeadend(item.deadend, maxChars) })),
285
+ };
286
+ }
120
287
  export async function verifyDeadend(startPath, deadendId) {
121
288
  const projectRoot = await resolveProjectRoot(startPath);
122
289
  return withLock(`${locksDirectory(projectRoot)}/deadends.lock`, async () => {
@@ -0,0 +1,173 @@
1
+ import crypto from 'node:crypto';
2
+ import { access, readdir } from 'node:fs/promises';
3
+ import { resolveProjectRoot } from '../project/project-resolver.js';
4
+ import { filterPaths } from '../capture/context-filter.js';
5
+ import { atomicWriteJson } from '../storage/atomic-write.js';
6
+ import { isRecord, readJson } from '../storage/json-store.js';
7
+ import { contextMetadataPath, contextsRoot, locksDirectory, notesPath, snapshotPath, snapshotsDirectory } from '../storage/layout.js';
8
+ import { withLock } from '../storage/lock.js';
9
+ function id() {
10
+ return `note-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
11
+ }
12
+ function isNotFound(error) {
13
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
14
+ }
15
+ function isNote(value) {
16
+ return isRecord(value)
17
+ && value.schemaVersion === 1
18
+ && typeof value.noteId === 'string'
19
+ && ['problem', 'action', 'observation', 'decision'].includes(value.kind)
20
+ && typeof value.message === 'string'
21
+ && value.message.trim().length > 0
22
+ && ['agent', 'human'].includes(value.actor)
23
+ && (typeof value.contextId === 'string' || value.contextId === null)
24
+ && (typeof value.snapshotId === 'string' || value.snapshotId === null)
25
+ && Array.isArray(value.relatedFiles) && value.relatedFiles.every((item) => typeof item === 'string')
26
+ && ['open', 'resolved'].includes(value.status)
27
+ && (typeof value.absorbedBySnapshotId === 'string' || value.absorbedBySnapshotId === null)
28
+ && typeof value.createdAt === 'string'
29
+ && typeof value.updatedAt === 'string';
30
+ }
31
+ function isNoteFile(value) {
32
+ return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items) && value.items.every(isNote);
33
+ }
34
+ async function loadNotes(projectRoot) {
35
+ try {
36
+ return await readJson(notesPath(projectRoot), isNoteFile);
37
+ }
38
+ catch (error) {
39
+ if (isNotFound(error))
40
+ return { schemaVersion: 1, items: [] };
41
+ throw error;
42
+ }
43
+ }
44
+ async function validateSource(projectRoot, contextId, snapshotId) {
45
+ if (!contextId && snapshotId)
46
+ throw new Error('source snapshot requires source context');
47
+ if (!contextId)
48
+ return;
49
+ await access(contextMetadataPath(projectRoot, contextId));
50
+ if (snapshotId)
51
+ await access(snapshotPath(projectRoot, contextId, snapshotId));
52
+ }
53
+ function limited(value, budget) {
54
+ return value.length <= budget ? value : value.slice(0, Math.max(0, budget - 1)) + '…';
55
+ }
56
+ export async function addNote(startPath, message, input = {}) {
57
+ const projectRoot = await resolveProjectRoot(startPath);
58
+ const normalizedMessage = message.trim();
59
+ if (!normalizedMessage)
60
+ throw new Error('note message must not be empty');
61
+ await validateSource(projectRoot, input.contextId, input.snapshotId);
62
+ const relatedFiles = await filterPaths(projectRoot, (input.relatedFiles ?? []).map((item) => item.trim()).filter(Boolean));
63
+ return withLock(`${locksDirectory(projectRoot)}/notes.lock`, async () => {
64
+ const file = await loadNotes(projectRoot);
65
+ const now = new Date().toISOString();
66
+ const note = {
67
+ noteId: id(),
68
+ schemaVersion: 1,
69
+ kind: input.kind ?? 'observation',
70
+ message: normalizedMessage,
71
+ actor: input.actor ?? 'agent',
72
+ contextId: input.contextId ?? null,
73
+ snapshotId: input.snapshotId ?? null,
74
+ relatedFiles,
75
+ status: input.status ?? 'open',
76
+ absorbedBySnapshotId: null,
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ };
80
+ await atomicWriteJson(notesPath(projectRoot), { schemaVersion: 1, items: [...file.items, note] });
81
+ return note;
82
+ });
83
+ }
84
+ export async function listNotes(startPath, query = {}) {
85
+ const projectRoot = await resolveProjectRoot(startPath);
86
+ const file = await loadNotes(projectRoot);
87
+ const limit = query.limit ?? 50;
88
+ const maxChars = query.maxChars ?? 500;
89
+ return file.items
90
+ .filter((note) => !query.contextId || note.contextId === query.contextId)
91
+ .filter((note) => !query.openOnly || (note.status === 'open' && note.absorbedBySnapshotId === null))
92
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
93
+ .slice(0, limit)
94
+ .map((note) => ({ ...note, message: limited(note.message, maxChars) }));
95
+ }
96
+ function noteActivity(note) {
97
+ if (!note.contextId)
98
+ return null;
99
+ return {
100
+ type: 'note',
101
+ occurredAt: note.createdAt,
102
+ contextId: note.contextId,
103
+ snapshotId: note.snapshotId ?? undefined,
104
+ noteId: note.noteId,
105
+ kind: note.kind,
106
+ message: note.message,
107
+ actor: note.actor,
108
+ status: note.status,
109
+ absorbedBySnapshotId: note.absorbedBySnapshotId,
110
+ };
111
+ }
112
+ async function checkpointActivities(projectRoot, contextId) {
113
+ const items = [];
114
+ let contextIds;
115
+ try {
116
+ contextIds = await readdir(contextsRoot(projectRoot));
117
+ }
118
+ catch {
119
+ contextIds = [];
120
+ }
121
+ for (const id of contextIds) {
122
+ if (contextId && id !== contextId)
123
+ continue;
124
+ try {
125
+ const context = await readJson(contextMetadataPath(projectRoot, id), (value) => isRecord(value) && typeof value.id === 'string' && typeof value.title === 'string' && (typeof value.currentSnapshotId === 'string' || value.currentSnapshotId === null));
126
+ if (!context.currentSnapshotId)
127
+ continue;
128
+ const snapshots = await readdir(snapshotsDirectory(projectRoot, id));
129
+ for (const fileName of snapshots.filter((name) => name.endsWith('.json'))) {
130
+ const snapshot = await readJson(snapshotPath(projectRoot, id, fileName.slice(0, -5)), (value) => isRecord(value) && value.schemaVersion === 1 && typeof value.snapshotId === 'string' && typeof value.createdAt === 'string' && (value.mode === 'baseline' || value.mode === 'patch'));
131
+ items.push({ type: 'checkpoint', occurredAt: snapshot.createdAt, contextId: id, snapshotId: snapshot.snapshotId, title: context.title });
132
+ }
133
+ }
134
+ catch {
135
+ // Doctor reports malformed activity sources.
136
+ }
137
+ }
138
+ return items;
139
+ }
140
+ export async function listActivity(startPath, query = {}) {
141
+ const projectRoot = await resolveProjectRoot(startPath);
142
+ const notes = await loadNotes(projectRoot);
143
+ const noteItems = notes.items.map(noteActivity).filter((item) => item !== null);
144
+ const checkpointItems = await checkpointActivities(projectRoot, query.contextId);
145
+ const maxChars = query.maxChars ?? 500;
146
+ const items = [...noteItems, ...checkpointItems]
147
+ .filter((item) => !query.contextId || item.contextId === query.contextId)
148
+ .filter((item) => !query.since || item.occurredAt >= query.since)
149
+ .filter((item) => !query.openOnly || (item.type === 'note' && item.status === 'open' && item.absorbedBySnapshotId === null))
150
+ .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))
151
+ .slice(0, query.limit ?? 50)
152
+ .map((item) => item.message ? { ...item, message: limited(item.message, maxChars) } : item);
153
+ return { projectRoot, items };
154
+ }
155
+ export async function absorbNotes(startPath, contextId, snapshotId) {
156
+ const projectRoot = await resolveProjectRoot(startPath);
157
+ return withLock(`${locksDirectory(projectRoot)}/notes.lock`, async () => {
158
+ const file = await loadNotes(projectRoot);
159
+ let absorbed = 0;
160
+ const items = file.items.map((note) => {
161
+ if (note.contextId !== contextId || note.absorbedBySnapshotId !== null)
162
+ return note;
163
+ absorbed += 1;
164
+ return { ...note, absorbedBySnapshotId: snapshotId, updatedAt: new Date().toISOString() };
165
+ });
166
+ if (absorbed > 0)
167
+ await atomicWriteJson(notesPath(projectRoot), { schemaVersion: 1, items });
168
+ return absorbed;
169
+ });
170
+ }
171
+ export function isValidNote(value) {
172
+ return isNote(value);
173
+ }
@@ -8,6 +8,7 @@ import { withLock } from '../storage/lock.js';
8
8
  import { contextDirectory, contextMetadataPath, contextsRoot, indexPath, locksDirectory, snapshotPath, snapshotsDirectory, } from '../storage/layout.js';
9
9
  import { loadManifest } from './init.js';
10
10
  import { filterPaths } from '../capture/context-filter.js';
11
+ import { absorbNotes } from './notes.js';
11
12
  const emptyContext = () => ({
12
13
  progressSummary: '',
13
14
  lastError: null,
@@ -46,6 +47,29 @@ function limitedList(values, budget) {
46
47
  function applyPatch(base, patch) {
47
48
  return { ...base, ...patch };
48
49
  }
50
+ function isContextPatch(value) {
51
+ if (!isRecord(value))
52
+ return false;
53
+ const fields = ['progressSummary', 'lastError', 'completed', 'pendingTasks', 'decisions', 'risks', 'relatedFiles'];
54
+ if (Object.keys(value).some((key) => !fields.includes(key)))
55
+ return false;
56
+ return fields.every((field) => {
57
+ if (!(field in value))
58
+ return true;
59
+ const fieldValue = value[field];
60
+ if (field === 'lastError')
61
+ return typeof fieldValue === 'string' || fieldValue === null;
62
+ if (field === 'progressSummary')
63
+ return typeof fieldValue === 'string';
64
+ return isStringArray(fieldValue);
65
+ });
66
+ }
67
+ function normalizeText(value) {
68
+ return value.trim();
69
+ }
70
+ function normalizeList(values) {
71
+ return values.map(normalizeText).filter(Boolean);
72
+ }
49
73
  function diffContext(previous, current) {
50
74
  const patch = {};
51
75
  if (previous.progressSummary !== current.progressSummary)
@@ -65,16 +89,17 @@ function diffContext(previous, current) {
65
89
  return patch;
66
90
  }
67
91
  async function contextFromInput(projectRoot, input, previous) {
68
- const relatedFiles = await filterPaths(projectRoot, input.relatedFiles ?? previous?.relatedFiles ?? []);
69
- return {
70
- progressSummary: input.progressSummary ?? previous?.progressSummary ?? '',
71
- lastError: input.lastError === undefined ? previous?.lastError ?? null : input.lastError,
72
- completed: input.completed ?? previous?.completed ?? [],
73
- pendingTasks: input.pendingTasks ?? previous?.pendingTasks ?? [],
74
- decisions: input.decisions ?? previous?.decisions ?? [],
75
- risks: input.risks ?? previous?.risks ?? [],
76
- relatedFiles,
77
- };
92
+ const progressSummary = input.progressSummary === undefined ? previous?.progressSummary ?? '' : normalizeText(input.progressSummary);
93
+ const lastError = input.lastError === undefined
94
+ ? previous?.lastError ?? null
95
+ : normalizeText(input.lastError ?? '') || null;
96
+ const completed = input.completed === undefined ? previous?.completed ?? [] : normalizeList(input.completed);
97
+ const pendingTasks = input.pendingTasks === undefined ? previous?.pendingTasks ?? [] : normalizeList(input.pendingTasks);
98
+ const decisions = input.decisions === undefined ? previous?.decisions ?? [] : normalizeList(input.decisions);
99
+ const risks = input.risks === undefined ? previous?.risks ?? [] : normalizeList(input.risks);
100
+ const relatedFilesInput = input.relatedFiles === undefined ? previous?.relatedFiles ?? [] : normalizeList(input.relatedFiles);
101
+ const relatedFiles = await filterPaths(projectRoot, relatedFilesInput);
102
+ return { progressSummary, lastError, completed, pendingTasks, decisions, risks, relatedFiles };
78
103
  }
79
104
  function isStructuredContext(value) {
80
105
  return isRecord(value)
@@ -126,7 +151,7 @@ async function loadSnapshot(projectRoot, contextId, snapshotId) {
126
151
  }
127
152
  return snapshot;
128
153
  }
129
- async function rebuildSnapshot(projectRoot, contextId, snapshotId, seen = new Set()) {
154
+ export async function rebuildSnapshot(projectRoot, contextId, snapshotId, seen = new Set()) {
130
155
  if (seen.has(snapshotId))
131
156
  throw new Error(`snapshot cycle detected at ${snapshotId}`);
132
157
  seen.add(snapshotId);
@@ -139,7 +164,7 @@ async function rebuildSnapshot(projectRoot, contextId, snapshotId, seen = new Se
139
164
  content = snapshot.content;
140
165
  }
141
166
  else {
142
- if (!snapshot.parentSnapshotId || !snapshot.patch || !isRecord(snapshot.patch))
167
+ if (!snapshot.parentSnapshotId || !snapshot.patch || !isContextPatch(snapshot.patch))
143
168
  throw new Error(`incomplete patch snapshot ${snapshotId}`);
144
169
  const parentSnapshot = await loadSnapshot(projectRoot, contextId, snapshot.parentSnapshotId);
145
170
  if (parentSnapshot.baseSnapshotId !== snapshot.baseSnapshotId)
@@ -151,7 +176,7 @@ async function rebuildSnapshot(projectRoot, contextId, snapshotId, seen = new Se
151
176
  }
152
177
  return content;
153
178
  }
154
- export async function rebuildIndex(projectRoot) {
179
+ export async function rebuildIndex(projectRoot, persist = true) {
155
180
  const contexts = [];
156
181
  let entries = [];
157
182
  try {
@@ -178,7 +203,8 @@ export async function rebuildIndex(projectRoot) {
178
203
  }
179
204
  }
180
205
  const index = { schemaVersion: 1, rebuiltAt: new Date().toISOString(), contexts };
181
- await atomicWriteJson(indexPath(projectRoot), index);
206
+ if (persist)
207
+ await atomicWriteJson(indexPath(projectRoot), index);
182
208
  return index;
183
209
  }
184
210
  async function updateIndex(projectRoot, entry) {
@@ -279,6 +305,8 @@ export async function checkpoint(startPath, input, options = {}) {
279
305
  currentSnapshotId: snapshotId,
280
306
  updatedAt: now,
281
307
  });
308
+ if (input.absorbNotes)
309
+ await absorbNotes(projectRoot, contextId, snapshotId);
282
310
  return { status: 'saved', context: updatedContext, snapshot };
283
311
  });
284
312
  }
@@ -35,3 +35,6 @@ export function knowledgePath(projectRoot) {
35
35
  export function deadendsPath(projectRoot) {
36
36
  return path.join(contextRoot(projectRoot), 'deadends.json');
37
37
  }
38
+ export function notesPath(projectRoot) {
39
+ return path.join(contextRoot(projectRoot), 'notes.json');
40
+ }
@@ -0,0 +1 @@
1
+ export const VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluffy-context",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "面向 AI 编程会话的本地上下文运行时 CLI",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -18,6 +18,11 @@
18
18
  "bin": {
19
19
  "ctx": "dist/src/cli/main.js"
20
20
  },
21
+ "exports": {
22
+ ".": "./dist/src/agent/index.js",
23
+ "./agent": "./dist/src/agent/index.js",
24
+ "./version": "./dist/src/version.js"
25
+ },
21
26
  "files": [
22
27
  "dist/src"
23
28
  ],