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 +21 -0
- package/README.md +55 -4
- package/dist/src/agent/api.js +133 -0
- package/dist/src/agent/index.js +1 -0
- package/dist/src/cli/main.js +288 -30
- package/dist/src/runtime/diagnostics.js +34 -5
- package/dist/src/runtime/knowledge.js +179 -12
- package/dist/src/runtime/notes.js +173 -0
- package/dist/src/runtime/runtime.js +42 -14
- package/dist/src/storage/layout.js +3 -0
- package/dist/src/version.js +1 -0
- package/package.json +6 -1
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
|
|
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
|
-
|
|
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';
|
package/dist/src/cli/main.js
CHANGED
|
@@ -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 (
|
|
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)
|
|
19
|
+
return value === undefined ? undefined : value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
24
20
|
}
|
|
25
|
-
function positionals(args,
|
|
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('
|
|
29
|
-
if (
|
|
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
|
|
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
|
|
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(
|
|
226
|
+
print(VERSION);
|
|
66
227
|
return;
|
|
67
228
|
}
|
|
68
|
-
if (args.length === 0 || args
|
|
69
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
|
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 '
|
|
115
|
-
if (args[1] === '
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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') ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
if (snapshotId)
|
|
27
|
-
await
|
|
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:
|
|
59
|
-
statement,
|
|
60
|
-
scope:
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 || !
|
|
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
|
-
|
|
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.
|
|
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
|
],
|