fluffy-context 0.1.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/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # Context Runtime
2
+
3
+ Context Runtime 是一个面向 AI 编程会话的本地上下文运行时 CLI。它把一次任务的进度、决策、待办、风险、知识和已验证死路保存为可恢复的结构化状态,帮助 Agent 在新会话中快速完成交接,而不是重新阅读大量项目内容。
4
+
5
+ 它更接近“Context 的 Git”,而不是代码备份工具:Git 仍然负责代码和真实文件变更,Context Runtime 负责 AI 工作状态的版本化保存与恢复。
6
+
7
+ ## 适用场景
8
+
9
+ - 下班前保存当前任务状态,第二天继续工作。
10
+ - 保存完成项、待办、阻塞、决策和风险。
11
+ - 将可复用业务知识独立记录,并在确认后纳入知识库。
12
+ - 记录已经验证不可行的方案,避免新会话重复探索。
13
+ - 通过摘要预算恢复上下文,减少不必要的 Token 消耗。
14
+
15
+ ## 环境要求
16
+
17
+ - Node.js `>=20.19.0`
18
+ - Git 可选。项目位于 Git 仓库内时,CLI 会记录当前分支和 commit 信息。
19
+
20
+ ## 安装
21
+
22
+ 发布后可以使用 npm 全局安装:
23
+
24
+ ```bash
25
+ npm install --global fluffy-context
26
+ ```
27
+
28
+ 也可以在项目目录中直接运行:
29
+
30
+ ```bash
31
+ npx fluffy-context --help
32
+ ```
33
+
34
+ ## 快速开始
35
+
36
+ 在项目根目录初始化 Context Runtime:
37
+
38
+ ```bash
39
+ ctx init
40
+ ```
41
+
42
+ 这会创建以下本地文件:
43
+
44
+ ```text
45
+ .context/
46
+ ├── manifest.json
47
+ ├── index.json
48
+ ├── contexts/
49
+ ├── locks/
50
+ ├── knowledge.json # 首次使用 learn 后创建
51
+ └── deadends.json # 首次使用 deadend 后创建
52
+ .contextignored
53
+ ```
54
+
55
+ 保存一次工作状态:
56
+
57
+ ```bash
58
+ ctx checkpoint \
59
+ --title "订单状态机重构" \
60
+ --progress "完成状态流转梳理,正在补充异常路径测试" \
61
+ --completed "梳理状态转移,确认幂等策略" \
62
+ --pending "补充异常路径测试,运行集成测试" \
63
+ --decisions "订单状态由服务端状态机统一维护" \
64
+ --risks "第三方回调可能重复到达" \
65
+ --files "src/order/state-machine.ts,src/order/state-machine.test.ts"
66
+ ```
67
+
68
+ 第二天恢复:
69
+
70
+ ```bash
71
+ ctx resume
72
+ ```
73
+
74
+ `resume` 默认返回轻量摘要,同时保留完整详情供按需使用。可以限制摘要字符数:
75
+
76
+ ```bash
77
+ ctx resume --max-chars 2000
78
+ ```
79
+
80
+ ## 常用命令
81
+
82
+ ### `ctx init`
83
+
84
+ 初始化项目的 Context Runtime 存储布局。重复执行是幂等的,不会覆盖已有 Context 或项目忽略规则。
85
+
86
+ ```bash
87
+ ctx init
88
+ ctx init path/to/project
89
+ ctx init --path path/to/project
90
+ ```
91
+
92
+ ### `ctx checkpoint`
93
+
94
+ 保存结构化工作状态。第一次保存创建 baseline,后续有变化时保存 patch;内容没有变化时返回 `no_change`。
95
+
96
+ ```bash
97
+ ctx checkpoint \
98
+ --context <context-id> \
99
+ --title "修复支付回调" \
100
+ --progress "已定位签名校验失败原因" \
101
+ --last-error "测试环境缺少回调凭据" \
102
+ --completed "复现问题,确认签名字段" \
103
+ --pending "补充回归测试" \
104
+ --decisions "保留原始请求体用于验签" \
105
+ --risks "旧版客户端字段格式不同" \
106
+ --files "src/payment/webhook.ts"
107
+ ```
108
+
109
+ 列表参数使用逗号分隔。`--context` 省略时会创建新的 Context。默认情况下,实际保存之间至少间隔 10 秒;短时间内有变化的保存会返回 `rate_limited`,没有变化则返回 `no_change`。
110
+
111
+ ### `ctx resume`
112
+
113
+ 恢复一个 active 或 stable Context。默认优先选择当前分支上最近更新的 Context,并返回 Git 分支/commit 是否发生漂移。
114
+
115
+ ```bash
116
+ ctx resume
117
+ ctx resume --context <context-id>
118
+ ctx resume --path path/to/project --max-chars 4000
119
+ ```
120
+
121
+ ### `ctx status`
122
+
123
+ 查看项目是否已初始化以及当前 Context 索引:
124
+
125
+ ```bash
126
+ ctx status
127
+ ```
128
+
129
+ ### `ctx doctor`
130
+
131
+ 检查 Manifest、存储目录、索引、Context 元数据、当前 Snapshot 和索引重建一致性:
132
+
133
+ ```bash
134
+ ctx doctor
135
+ ```
136
+
137
+ ### `ctx learn`
138
+
139
+ 记录一条待确认的项目知识。Knowledge 默认是 `candidate`,不会进入普通的已验证知识列表。
140
+
141
+ ```bash
142
+ ctx learn "订单取消后不能再次进入支付中状态" \
143
+ --scope project \
144
+ --context <context-id> \
145
+ --snapshot <snapshot-id> \
146
+ --evidence "src/order/state-machine.ts,订单服务接口约束"
147
+ ```
148
+
149
+ 查看和确认知识:
150
+
151
+ ```bash
152
+ ctx knowledge
153
+ ctx knowledge --all
154
+ ctx knowledge verify <knowledge-id>
155
+ ```
156
+
157
+ 不提供 `--context` 时,知识仍可以记录为项目级候选知识;如果提供来源,则对应 Context 和 Snapshot 必须存在。
158
+
159
+ ### `ctx deadend`
160
+
161
+ 记录一条已尝试但不可行的路径。Deadend 默认是 `candidate`,不会默认注入恢复摘要。
162
+
163
+ ```bash
164
+ ctx deadend \
165
+ --attempt "使用共享可变单例保存订单状态" \
166
+ --reason "并发测试出现跨用例状态泄漏" \
167
+ --scope project \
168
+ --context <context-id> \
169
+ --evidence "test/order-state.test.ts"
170
+ ```
171
+
172
+ 查看和确认死路:
173
+
174
+ ```bash
175
+ ctx deadends
176
+ ctx deadends --all
177
+ ctx deadend verify <deadend-id>
178
+ ```
179
+
180
+ ## `.contextignored`
181
+
182
+ `ctx init` 会在项目根目录创建 `.contextignored`。它用于配置不应作为上下文关联文件保存的路径,语法接近 `.gitignore`:
183
+
184
+ ```gitignore
185
+ # 私有目录
186
+ private/
187
+
188
+ # 本地生成文件
189
+ *.generated.ts
190
+
191
+ # 不纳入上下文的临时记录
192
+ notes/draft-*
193
+ ```
194
+
195
+ 内置保护规则始终优先,包括 `.context/`、`.git/`、依赖目录、构建产物、环境变量文件、密钥和常见凭据文件。项目规则不能通过否定规则覆盖这些保护。
196
+
197
+ ## 存储与安全边界
198
+
199
+ - Snapshot 按 baseline/patch 保存,不会每次复制整个项目文件树。
200
+ - `index.json` 是可重建索引,不是唯一业务数据来源。
201
+ - Context、Knowledge、Deadend 使用独立文件保存。
202
+ - 写入使用临时文件替换,并通过项目级锁避免并发覆盖。
203
+ - 默认只恢复摘要字段;完整结构化内容位于 `details` 中按需读取。
204
+ - CLI 不会读取或保存被内置保护规则排除的文件内容。
205
+
206
+ ## 命令输出
207
+
208
+ CLI 默认向标准输出写入格式化 JSON,适合被脚本、Agent 或 IDE 集成:
209
+
210
+ ```bash
211
+ ctx --help
212
+ ctx --version
213
+ ```
214
+
215
+ 错误信息写入标准错误,并以非零退出码结束。
216
+
217
+ ## 开发
218
+
219
+ 安装依赖并运行测试:
220
+
221
+ ```bash
222
+ npm install
223
+ npm test
224
+ ```
225
+
226
+ 只构建 TypeScript:
227
+
228
+ ```bash
229
+ npm run build
230
+ ```
231
+
232
+ 本项目核心运行时只依赖 Node.js 内置模块,当前没有运行时第三方依赖。
233
+
234
+ ## 当前范围
235
+
236
+ 当前版本聚焦本地单项目的可靠闭环:
237
+
238
+ ```text
239
+ init → checkpoint → resume
240
+ ```
241
+
242
+ 并提供基础的 Knowledge、Deadend、忽略规则、诊断、Snapshot 增量保存、摘要预算和保存限流能力。远程同步、多人协作、复杂语义检索、自动模型总结和 Context merge 不属于当前版本的已实现能力。
@@ -0,0 +1,86 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ignoredPath } from '../storage/layout.js';
4
+ const builtinPatterns = [
5
+ '.context/**',
6
+ '.git/**',
7
+ 'node_modules/**',
8
+ 'dist/**',
9
+ 'build/**',
10
+ 'coverage/**',
11
+ 'tmp/**',
12
+ '.cache/**',
13
+ '.env',
14
+ '.env.*',
15
+ '*.pem',
16
+ '*.key',
17
+ '*.p12',
18
+ '*.pfx',
19
+ 'credentials*',
20
+ '*credential*',
21
+ 'secret*',
22
+ '*token*',
23
+ ];
24
+ function globToRegExp(pattern) {
25
+ let source = '';
26
+ for (let index = 0; index < pattern.length; index += 1) {
27
+ const character = pattern[index];
28
+ if (character === '*') {
29
+ if (pattern[index + 1] === '*') {
30
+ source += '.*';
31
+ index += 1;
32
+ }
33
+ else {
34
+ source += '[^/]*';
35
+ }
36
+ }
37
+ else if (character === '?') {
38
+ source += '[^/]';
39
+ }
40
+ else {
41
+ source += /[\\^$+?.()|[\]{}]/.test(character) ? `\\${character}` : character;
42
+ }
43
+ }
44
+ return new RegExp(`^${source}$`);
45
+ }
46
+ function matches(pattern, relativePath) {
47
+ const directoryPattern = pattern.endsWith('/');
48
+ const normalized = pattern.replace(/^\//, '').replace(/\/$/, '');
49
+ const expression = globToRegExp(directoryPattern ? `${normalized}/**` : normalized);
50
+ return expression.test(relativePath) || (!pattern.startsWith('/') && expression.test(relativePath.split('/').slice(-1)[0]));
51
+ }
52
+ export async function readProjectRules(projectRoot) {
53
+ try {
54
+ const content = await readFile(ignoredPath(projectRoot), 'utf8');
55
+ return content
56
+ .split(/\r?\n/)
57
+ .map((line) => line.trim())
58
+ .filter((line) => line && !line.startsWith('#'))
59
+ .map((line) => ({ negated: line.startsWith('!'), pattern: line.replace(/^!/, '') }));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ export async function filterPaths(projectRoot, paths) {
66
+ const projectRules = await readProjectRules(projectRoot);
67
+ return paths
68
+ .map((filePath) => filePath.replaceAll('\\', '/').replace(/^\.\//, ''))
69
+ .filter((relativePath) => {
70
+ if (path.posix.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath))
71
+ return false;
72
+ const normalized = path.posix.normalize(relativePath);
73
+ if (normalized === '..' || normalized.startsWith('../'))
74
+ return false;
75
+ if (builtinPatterns.some((pattern) => matches(pattern, normalized)))
76
+ return false;
77
+ let included = true;
78
+ for (const rule of projectRules) {
79
+ if (matches(rule.pattern, normalized)) {
80
+ included = rule.negated;
81
+ }
82
+ }
83
+ return included;
84
+ })
85
+ .map((filePath) => path.posix.normalize(filePath));
86
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { checkpoint, resume } from '../runtime/runtime.js';
3
+ import { doctor, status } from '../runtime/diagnostics.js';
4
+ import { initProject } from '../runtime/init.js';
5
+ import { learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend } from '../runtime/knowledge.js';
6
+ function option(args, name) {
7
+ const index = args.indexOf(name);
8
+ if (index < 0)
9
+ return undefined;
10
+ const value = args[index + 1];
11
+ if (!value || value.startsWith('--'))
12
+ throw new Error(`missing value for ${name}`);
13
+ return value;
14
+ }
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
+ function listOption(args, name) {
22
+ const value = option(args, name);
23
+ return value ? value.split(',').map((item) => item.trim()).filter(Boolean) : undefined;
24
+ }
25
+ function positionals(args, options) {
26
+ const values = [];
27
+ for (let index = 0; index < args.length; index += 1) {
28
+ if (args[index].startsWith('--') || args[index] === '-m') {
29
+ if (options.includes(args[index]))
30
+ index += 1;
31
+ continue;
32
+ }
33
+ values.push(args[index]);
34
+ }
35
+ return values;
36
+ }
37
+ function validateOptions(args, allowed) {
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const argument = args[index];
40
+ if (!argument.startsWith('-'))
41
+ continue;
42
+ if (!allowed.includes(argument))
43
+ throw new Error(`unknown option ${argument}`);
44
+ if (argument !== '--all' && argument !== '--help' && argument !== '--version')
45
+ index += 1;
46
+ }
47
+ }
48
+ function numericOption(args, name, defaultValue) {
49
+ const value = option(args, name);
50
+ if (!value)
51
+ return defaultValue;
52
+ const parsed = Number(value);
53
+ if (!Number.isInteger(parsed) || parsed < 0)
54
+ throw new Error(`${name} must be a non-negative integer`);
55
+ return parsed;
56
+ }
57
+ function usage() {
58
+ return 'usage: ctx init|checkpoint|resume|status|doctor|learn|knowledge|deadend|deadends [options]';
59
+ }
60
+ function print(value) {
61
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
62
+ }
63
+ async function run(args) {
64
+ if (args.includes('--version')) {
65
+ print('0.1.0');
66
+ return;
67
+ }
68
+ if (args.length === 0 || args.includes('--help')) {
69
+ process.stdout.write(`${usage()}\n`);
70
+ return;
71
+ }
72
+ const command = args[0];
73
+ const target = option(args, '--path');
74
+ switch (command) {
75
+ case 'init':
76
+ print(await initProject(target ?? args[1]));
77
+ return;
78
+ case 'checkpoint': {
79
+ const input = {
80
+ contextId: option(args, '--context'),
81
+ title: option(args, '--title'),
82
+ progressSummary: option(args, '--progress'),
83
+ lastError: option(args, '--last-error') ?? null,
84
+ completed: listOption(args, '--completed'),
85
+ pendingTasks: listOption(args, '--pending'),
86
+ decisions: listOption(args, '--decisions'),
87
+ risks: listOption(args, '--risks'),
88
+ relatedFiles: listOption(args, '--files'),
89
+ };
90
+ print(await checkpoint(target, input));
91
+ return;
92
+ }
93
+ case 'resume':
94
+ validateOptions(args.slice(1), ['--path', '--context', '--max-chars']);
95
+ print(await resume(target, option(args, '--context'), numericOption(args, '--max-chars', 4000)));
96
+ return;
97
+ case 'status':
98
+ print(await status(target));
99
+ return;
100
+ case 'doctor':
101
+ print(await doctor(target));
102
+ return;
103
+ case 'learn': {
104
+ const statement = positionals(args.slice(1), ['--path', '--scope', '--context', '--snapshot', '--evidence']);
105
+ const input = {
106
+ scope: option(args, '--scope'),
107
+ sourceContextId: option(args, '--context'),
108
+ sourceSnapshotId: option(args, '--snapshot'),
109
+ evidence: listOption(args, '--evidence'),
110
+ };
111
+ print(await learnKnowledge(target, statement.join(' '), input));
112
+ return;
113
+ }
114
+ case 'knowledge':
115
+ if (args[1] === 'verify')
116
+ print(await verifyKnowledge(target, args[2]));
117
+ else
118
+ print(await listKnowledge(target, args.includes('--all')));
119
+ return;
120
+ case 'deadend':
121
+ if (args[1] === 'verify')
122
+ print(await verifyDeadend(target, args[2]));
123
+ else {
124
+ const input = {
125
+ scope: option(args, '--scope'),
126
+ sourceContextId: option(args, '--context'),
127
+ sourceSnapshotId: option(args, '--snapshot'),
128
+ evidence: listOption(args, '--evidence'),
129
+ };
130
+ print(await recordDeadend(target, option(args, '--attempt') ?? args[1], option(args, '--reason') ?? option(args, '-m') ?? '', input));
131
+ }
132
+ return;
133
+ case 'deadends':
134
+ print(await listDeadends(target, args.includes('--all')));
135
+ return;
136
+ default:
137
+ throw new Error(usage());
138
+ }
139
+ }
140
+ run(process.argv.slice(2)).catch((error) => {
141
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
142
+ process.exitCode = 1;
143
+ });
@@ -0,0 +1,23 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ async function git(args, cwd) {
5
+ try {
6
+ const result = await execFileAsync('git', args, { cwd, windowsHide: true });
7
+ return result.stdout.trim() || null;
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function readGitState(projectRoot) {
14
+ const root = await git(['rev-parse', '--show-toplevel'], projectRoot);
15
+ if (!root) {
16
+ return { root: null, branch: null, commit: null };
17
+ }
18
+ return {
19
+ root,
20
+ branch: await git(['branch', '--show-current'], projectRoot),
21
+ commit: await git(['rev-parse', 'HEAD'], projectRoot),
22
+ };
23
+ }
@@ -0,0 +1,39 @@
1
+ import { access, stat } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import path from 'node:path';
5
+ const execFileAsync = promisify(execFile);
6
+ async function gitRoot(directory) {
7
+ try {
8
+ const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
9
+ cwd: directory,
10
+ windowsHide: true,
11
+ });
12
+ return result.stdout.trim() || null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export async function resolveProjectRoot(startPath = process.cwd()) {
19
+ const initial = path.resolve(startPath);
20
+ const directory = (await stat(initial)).isDirectory() ? initial : path.dirname(initial);
21
+ const root = await gitRoot(directory);
22
+ if (root) {
23
+ return path.resolve(root);
24
+ }
25
+ let current = directory;
26
+ while (true) {
27
+ try {
28
+ await access(path.join(current, '.context'));
29
+ return current;
30
+ }
31
+ catch {
32
+ const parent = path.dirname(current);
33
+ if (parent === current) {
34
+ return directory;
35
+ }
36
+ current = parent;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,70 @@
1
+ import { access, readdir } from 'node:fs/promises';
2
+ import { resolveProjectRoot } from '../project/project-resolver.js';
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';
6
+ export async function status(startPath) {
7
+ const projectRoot = await resolveProjectRoot(startPath);
8
+ try {
9
+ const manifest = await readJson(manifestPath(projectRoot));
10
+ const index = await readJson(indexPath(projectRoot));
11
+ return {
12
+ initialized: true,
13
+ projectRoot,
14
+ layoutVersion: manifest.layoutVersion,
15
+ contextCount: index.contexts.length,
16
+ contexts: index.contexts,
17
+ };
18
+ }
19
+ catch {
20
+ return { initialized: false, projectRoot };
21
+ }
22
+ }
23
+ export async function doctor(startPath) {
24
+ const projectRoot = await resolveProjectRoot(startPath);
25
+ const checks = [];
26
+ let healthy = true;
27
+ const check = async (name, operation) => {
28
+ try {
29
+ await operation();
30
+ checks.push(`${name}: ok`);
31
+ }
32
+ catch (error) {
33
+ healthy = false;
34
+ checks.push(`${name}: ${error instanceof Error ? error.message : 'failed'}`);
35
+ }
36
+ };
37
+ await check('manifest', async () => {
38
+ const manifest = await readJson(manifestPath(projectRoot));
39
+ if (manifest.schemaVersion !== 1 || manifest.layoutVersion !== 'compact-json-v1' || manifest.projectRoot !== projectRoot) {
40
+ throw new Error('invalid manifest');
41
+ }
42
+ });
43
+ await check('context directory', () => access(contextRoot(projectRoot)));
44
+ await check('index', async () => {
45
+ const index = await readJson(indexPath(projectRoot));
46
+ if (index.schemaVersion !== 1 || !Array.isArray(index.contexts))
47
+ throw new Error('invalid index');
48
+ });
49
+ await check('contexts', async () => {
50
+ const entries = await readdir(contextsRoot(projectRoot));
51
+ for (const contextId of entries) {
52
+ const context = await readJson(contextMetadataPath(projectRoot, contextId));
53
+ if (context.id !== contextId || context.schemaVersion !== 1)
54
+ throw new Error(`invalid context ${contextId}`);
55
+ if (context.currentSnapshotId)
56
+ await access(snapshotPath(projectRoot, contextId, context.currentSnapshotId));
57
+ }
58
+ });
59
+ await check('index rebuild', async () => {
60
+ const stored = await readJson(indexPath(projectRoot));
61
+ const rebuilt = await rebuildIndex(projectRoot);
62
+ const rebuiltIds = new Set(rebuilt.contexts.map((entry) => entry.contextId));
63
+ const storedIds = new Set(stored.contexts.map((entry) => entry.contextId));
64
+ if (rebuiltIds.size !== rebuilt.contexts.length || storedIds.size !== stored.contexts.length)
65
+ throw new Error('duplicate index context id');
66
+ if (rebuiltIds.size !== storedIds.size || [...rebuiltIds].some((id) => !storedIds.has(id)))
67
+ throw new Error('index rebuild mismatch');
68
+ });
69
+ return { healthy, projectRoot, checks };
70
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdir, access, writeFile } from 'node:fs/promises';
2
+ import crypto from 'node:crypto';
3
+ import { resolveProjectRoot } from '../project/project-resolver.js';
4
+ import { atomicWriteJson } from '../storage/atomic-write.js';
5
+ import { readJson } from '../storage/json-store.js';
6
+ import { ignoredPath, indexPath, locksDirectory, manifestPath, contextsRoot, } from '../storage/layout.js';
7
+ const ignoredTemplate = `# Context Runtime project rules\n# Add paths that must never enter context collection.\n\n# Build output\ndist/\nbuild/\ncoverage/\n\n# Local secrets\n.env\n.env.*\n`;
8
+ export async function initProject(startPath) {
9
+ const projectRoot = await resolveProjectRoot(startPath);
10
+ const manifestFile = manifestPath(projectRoot);
11
+ const requiredPaths = [manifestFile, indexPath(projectRoot), contextsRoot(projectRoot), locksDirectory(projectRoot)];
12
+ let present = 0;
13
+ for (const requiredPath of requiredPaths) {
14
+ try {
15
+ await access(requiredPath);
16
+ present += 1;
17
+ }
18
+ catch {
19
+ // Missing path is handled below.
20
+ }
21
+ }
22
+ if (present === requiredPaths.length)
23
+ return { projectRoot, alreadyInitialized: true };
24
+ if (present > 0)
25
+ throw new Error('context layout is incomplete; run ctx doctor before repairing it');
26
+ await mkdir(contextsRoot(projectRoot), { recursive: true });
27
+ await mkdir(locksDirectory(projectRoot), { recursive: true });
28
+ const manifest = {
29
+ schemaVersion: 1,
30
+ layoutVersion: 'compact-json-v1',
31
+ projectId: crypto.createHash('sha256').update(projectRoot).digest('hex').slice(0, 16),
32
+ projectRoot,
33
+ initializedAt: new Date().toISOString(),
34
+ };
35
+ await atomicWriteJson(manifestFile, manifest);
36
+ await atomicWriteJson(indexPath(projectRoot), { schemaVersion: 1, rebuiltAt: new Date().toISOString(), contexts: [] });
37
+ try {
38
+ await access(ignoredPath(projectRoot));
39
+ }
40
+ catch {
41
+ await writeFile(ignoredPath(projectRoot), ignoredTemplate, 'utf8');
42
+ }
43
+ return { projectRoot, alreadyInitialized: false };
44
+ }
45
+ export async function loadManifest(projectRoot) {
46
+ return readJson(manifestPath(projectRoot));
47
+ }
@@ -0,0 +1,132 @@
1
+ import crypto from 'node:crypto';
2
+ import { access } from 'node:fs/promises';
3
+ import { resolveProjectRoot } from '../project/project-resolver.js';
4
+ import { atomicWriteJson } from '../storage/atomic-write.js';
5
+ import { isRecord, readJson } from '../storage/json-store.js';
6
+ import { withLock } from '../storage/lock.js';
7
+ import { contextMetadataPath, deadendsPath, knowledgePath, locksDirectory, snapshotPath } from '../storage/layout.js';
8
+ function id(prefix) {
9
+ return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
10
+ }
11
+ function isNotFound(error) {
12
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
13
+ }
14
+ function isKnowledgeFile(value) {
15
+ return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
16
+ }
17
+ function isDeadendFile(value) {
18
+ return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
19
+ }
20
+ async function validateSource(projectRoot, contextId, snapshotId) {
21
+ if (!contextId && snapshotId)
22
+ throw new Error('source snapshot requires source context');
23
+ if (!contextId)
24
+ return;
25
+ await access(contextMetadataPath(projectRoot, contextId));
26
+ if (snapshotId)
27
+ await access(snapshotPath(projectRoot, contextId, snapshotId));
28
+ }
29
+ async function loadKnowledge(projectRoot) {
30
+ try {
31
+ return await readJson(knowledgePath(projectRoot), isKnowledgeFile);
32
+ }
33
+ catch (error) {
34
+ if (isNotFound(error))
35
+ return { schemaVersion: 1, items: [] };
36
+ throw error;
37
+ }
38
+ }
39
+ async function loadDeadends(projectRoot) {
40
+ try {
41
+ return await readJson(deadendsPath(projectRoot), isDeadendFile);
42
+ }
43
+ catch (error) {
44
+ if (isNotFound(error))
45
+ return { schemaVersion: 1, items: [] };
46
+ throw error;
47
+ }
48
+ }
49
+ export async function learnKnowledge(startPath, statement, scopeOrInput = 'project') {
50
+ const projectRoot = await resolveProjectRoot(startPath);
51
+ const input = typeof scopeOrInput === 'string' ? { scope: scopeOrInput } : scopeOrInput;
52
+ await validateSource(projectRoot, input.sourceContextId, input.sourceSnapshotId);
53
+ return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
54
+ const file = await loadKnowledge(projectRoot);
55
+ const now = new Date().toISOString();
56
+ const item = {
57
+ knowledgeId: id('know'),
58
+ kind: 'fact',
59
+ statement,
60
+ scope: input.scope ?? 'project',
61
+ confidence: 0.5,
62
+ status: 'candidate',
63
+ sourceContextIds: input.sourceContextId ? [input.sourceContextId] : [],
64
+ sourceSnapshotIds: input.sourceSnapshotId ? [input.sourceSnapshotId] : [],
65
+ supportingEvidence: input.evidence ?? [],
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+ await atomicWriteJson(knowledgePath(projectRoot), { schemaVersion: 1, items: [...file.items, item] });
70
+ return item;
71
+ });
72
+ }
73
+ export async function listKnowledge(startPath, includeUnverified = false) {
74
+ const projectRoot = await resolveProjectRoot(startPath);
75
+ const file = await loadKnowledge(projectRoot);
76
+ return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
77
+ }
78
+ export async function verifyKnowledge(startPath, knowledgeId) {
79
+ const projectRoot = await resolveProjectRoot(startPath);
80
+ return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
81
+ const file = await loadKnowledge(projectRoot);
82
+ const item = file.items.find((candidate) => candidate.knowledgeId === knowledgeId);
83
+ if (!item)
84
+ throw new Error(`knowledge not found: ${knowledgeId}`);
85
+ item.status = 'verified';
86
+ item.confidence = Math.max(item.confidence, 0.8);
87
+ item.updatedAt = new Date().toISOString();
88
+ await atomicWriteJson(knowledgePath(projectRoot), file);
89
+ return item;
90
+ });
91
+ }
92
+ export async function recordDeadend(startPath, attempt, reason, scopeOrInput = 'project') {
93
+ const projectRoot = await resolveProjectRoot(startPath);
94
+ const input = typeof scopeOrInput === 'string' ? { scope: scopeOrInput } : scopeOrInput;
95
+ await validateSource(projectRoot, input.sourceContextId, input.sourceSnapshotId);
96
+ return withLock(`${locksDirectory(projectRoot)}/deadends.lock`, async () => {
97
+ const file = await loadDeadends(projectRoot);
98
+ const now = new Date().toISOString();
99
+ const item = {
100
+ deadendId: id('dead'),
101
+ attempt,
102
+ reason,
103
+ observedEvidence: input.evidence ?? [],
104
+ scope: input.scope ?? 'project',
105
+ status: 'candidate',
106
+ sourceContextId: input.sourceContextId ?? null,
107
+ sourceSnapshotId: input.sourceSnapshotId ?? null,
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ };
111
+ await atomicWriteJson(deadendsPath(projectRoot), { schemaVersion: 1, items: [...file.items, item] });
112
+ return item;
113
+ });
114
+ }
115
+ export async function listDeadends(startPath, includeUnverified = false) {
116
+ const projectRoot = await resolveProjectRoot(startPath);
117
+ const file = await loadDeadends(projectRoot);
118
+ return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
119
+ }
120
+ export async function verifyDeadend(startPath, deadendId) {
121
+ const projectRoot = await resolveProjectRoot(startPath);
122
+ return withLock(`${locksDirectory(projectRoot)}/deadends.lock`, async () => {
123
+ const file = await loadDeadends(projectRoot);
124
+ const item = file.items.find((candidate) => candidate.deadendId === deadendId);
125
+ if (!item)
126
+ throw new Error(`deadend not found: ${deadendId}`);
127
+ item.status = 'verified';
128
+ item.updatedAt = new Date().toISOString();
129
+ await atomicWriteJson(deadendsPath(projectRoot), file);
130
+ return item;
131
+ });
132
+ }
@@ -0,0 +1,317 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { readGitState } from '../git/git-adapter.js';
4
+ import { resolveProjectRoot } from '../project/project-resolver.js';
5
+ import { atomicWriteJson } from '../storage/atomic-write.js';
6
+ import { isRecord, readJson } from '../storage/json-store.js';
7
+ import { withLock } from '../storage/lock.js';
8
+ import { contextDirectory, contextMetadataPath, contextsRoot, indexPath, locksDirectory, snapshotPath, snapshotsDirectory, } from '../storage/layout.js';
9
+ import { loadManifest } from './init.js';
10
+ import { filterPaths } from '../capture/context-filter.js';
11
+ const emptyContext = () => ({
12
+ progressSummary: '',
13
+ lastError: null,
14
+ completed: [],
15
+ pendingTasks: [],
16
+ decisions: [],
17
+ risks: [],
18
+ relatedFiles: [],
19
+ });
20
+ function stableJson(value) {
21
+ return JSON.stringify(value);
22
+ }
23
+ function hash(value) {
24
+ return crypto.createHash('sha256').update(stableJson(value)).digest('hex');
25
+ }
26
+ function createId(prefix) {
27
+ return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
28
+ }
29
+ function isNotFound(error) {
30
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
31
+ }
32
+ function limited(value, budget) {
33
+ return value.length <= budget ? value : value.slice(0, Math.max(0, budget - 1)) + '…';
34
+ }
35
+ function limitedList(values, budget) {
36
+ const result = [];
37
+ for (const value of values) {
38
+ if (budget.remaining <= 0)
39
+ break;
40
+ const item = limited(value, budget.remaining);
41
+ result.push(item);
42
+ budget.remaining -= item.length;
43
+ }
44
+ return result;
45
+ }
46
+ function applyPatch(base, patch) {
47
+ return { ...base, ...patch };
48
+ }
49
+ function diffContext(previous, current) {
50
+ const patch = {};
51
+ if (previous.progressSummary !== current.progressSummary)
52
+ patch.progressSummary = current.progressSummary;
53
+ if (previous.lastError !== current.lastError)
54
+ patch.lastError = current.lastError;
55
+ if (stableJson(previous.completed) !== stableJson(current.completed))
56
+ patch.completed = current.completed;
57
+ if (stableJson(previous.pendingTasks) !== stableJson(current.pendingTasks))
58
+ patch.pendingTasks = current.pendingTasks;
59
+ if (stableJson(previous.decisions) !== stableJson(current.decisions))
60
+ patch.decisions = current.decisions;
61
+ if (stableJson(previous.risks) !== stableJson(current.risks))
62
+ patch.risks = current.risks;
63
+ if (stableJson(previous.relatedFiles) !== stableJson(current.relatedFiles))
64
+ patch.relatedFiles = current.relatedFiles;
65
+ return patch;
66
+ }
67
+ 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
+ };
78
+ }
79
+ function isStructuredContext(value) {
80
+ return isRecord(value)
81
+ && typeof value.progressSummary === 'string'
82
+ && (typeof value.lastError === 'string' || value.lastError === null)
83
+ && ['completed', 'pendingTasks', 'decisions', 'risks', 'relatedFiles'].every((key) => isStringArray(value[key]));
84
+ }
85
+ function isSnapshot(value) {
86
+ return isRecord(value)
87
+ && value.schemaVersion === 1
88
+ && typeof value.snapshotId === 'string'
89
+ && typeof value.contextId === 'string'
90
+ && (typeof value.parentSnapshotId === 'string' || value.parentSnapshotId === null)
91
+ && typeof value.baseSnapshotId === 'string'
92
+ && (value.mode === 'baseline' || value.mode === 'patch')
93
+ && typeof value.createdAt === 'string'
94
+ && (typeof value.branch === 'string' || value.branch === null)
95
+ && (typeof value.commit === 'string' || value.commit === null)
96
+ && typeof value.contentHash === 'string'
97
+ && typeof value.rebuildHash === 'string';
98
+ }
99
+ function isStringArray(value) {
100
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
101
+ }
102
+ function isContextMetadata(value, contextId) {
103
+ return isRecord(value)
104
+ && value.schemaVersion === 1
105
+ && value.id === contextId
106
+ && typeof value.title === 'string'
107
+ && typeof value.projectRoot === 'string'
108
+ && (typeof value.branch === 'string' || value.branch === null)
109
+ && (typeof value.commit === 'string' || value.commit === null)
110
+ && typeof value.createdAt === 'string'
111
+ && typeof value.updatedAt === 'string'
112
+ && (typeof value.lastUsedAt === 'string' || value.lastUsedAt === null)
113
+ && typeof value.usageCount === 'number'
114
+ && ['draft', 'active', 'stable', 'archived', 'deleted'].includes(value.status)
115
+ && (typeof value.currentSnapshotId === 'string' || value.currentSnapshotId === null)
116
+ && isStringArray(value.tags)
117
+ && isStringArray(value.dependencies);
118
+ }
119
+ async function loadContext(projectRoot, contextId) {
120
+ return readJson(contextMetadataPath(projectRoot, contextId), (value) => isContextMetadata(value, contextId));
121
+ }
122
+ async function loadSnapshot(projectRoot, contextId, snapshotId) {
123
+ const snapshot = await readJson(snapshotPath(projectRoot, contextId, snapshotId), isSnapshot);
124
+ if (snapshot.contextId !== contextId || snapshot.snapshotId !== snapshotId) {
125
+ throw new Error(`invalid snapshot ${snapshotId}`);
126
+ }
127
+ return snapshot;
128
+ }
129
+ async function rebuildSnapshot(projectRoot, contextId, snapshotId, seen = new Set()) {
130
+ if (seen.has(snapshotId))
131
+ throw new Error(`snapshot cycle detected at ${snapshotId}`);
132
+ seen.add(snapshotId);
133
+ const snapshot = await loadSnapshot(projectRoot, contextId, snapshotId);
134
+ let content;
135
+ if (snapshot.mode === 'baseline') {
136
+ if (!snapshot.content || !isStructuredContext(snapshot.content) || snapshot.parentSnapshotId !== null || snapshot.baseSnapshotId !== snapshot.snapshotId) {
137
+ throw new Error(`invalid baseline snapshot ${snapshotId}`);
138
+ }
139
+ content = snapshot.content;
140
+ }
141
+ else {
142
+ if (!snapshot.parentSnapshotId || !snapshot.patch || !isRecord(snapshot.patch))
143
+ throw new Error(`incomplete patch snapshot ${snapshotId}`);
144
+ const parentSnapshot = await loadSnapshot(projectRoot, contextId, snapshot.parentSnapshotId);
145
+ if (parentSnapshot.baseSnapshotId !== snapshot.baseSnapshotId)
146
+ throw new Error(`base_snapshot_mismatch for ${snapshotId}`);
147
+ content = applyPatch(await rebuildSnapshot(projectRoot, contextId, snapshot.parentSnapshotId, seen), snapshot.patch);
148
+ }
149
+ if (hash(content) !== snapshot.contentHash || hash(content) !== snapshot.rebuildHash) {
150
+ throw new Error(`snapshot hash mismatch for ${snapshotId}`);
151
+ }
152
+ return content;
153
+ }
154
+ export async function rebuildIndex(projectRoot) {
155
+ const contexts = [];
156
+ let entries = [];
157
+ try {
158
+ entries = await (await import('node:fs/promises')).readdir(contextsRoot(projectRoot));
159
+ }
160
+ catch {
161
+ entries = [];
162
+ }
163
+ for (const contextId of entries) {
164
+ try {
165
+ const context = await loadContext(projectRoot, contextId);
166
+ contexts.push({
167
+ contextId: context.id,
168
+ title: context.title,
169
+ status: context.status,
170
+ branch: context.branch,
171
+ commit: context.commit,
172
+ currentSnapshotId: context.currentSnapshotId,
173
+ updatedAt: context.updatedAt,
174
+ });
175
+ }
176
+ catch {
177
+ // Doctor reports malformed context metadata; it is not indexed.
178
+ }
179
+ }
180
+ const index = { schemaVersion: 1, rebuiltAt: new Date().toISOString(), contexts };
181
+ await atomicWriteJson(indexPath(projectRoot), index);
182
+ return index;
183
+ }
184
+ async function updateIndex(projectRoot, entry) {
185
+ const index = await rebuildIndex(projectRoot);
186
+ const contexts = index.contexts.filter((item) => item.contextId !== entry.contextId);
187
+ contexts.push(entry);
188
+ await atomicWriteJson(indexPath(projectRoot), { schemaVersion: 1, rebuiltAt: new Date().toISOString(), contexts });
189
+ }
190
+ export async function checkpoint(startPath, input, options = {}) {
191
+ const projectRoot = await resolveProjectRoot(startPath);
192
+ await loadManifest(projectRoot);
193
+ const git = await readGitState(projectRoot);
194
+ const contextId = input.contextId ?? createId('ctx');
195
+ const lockPath = `${locksDirectory(projectRoot)}/checkpoint.lock`;
196
+ return withLock(lockPath, async () => {
197
+ let context;
198
+ let previous = emptyContext();
199
+ let previousSnapshot = null;
200
+ try {
201
+ context = await loadContext(projectRoot, contextId);
202
+ if (context.currentSnapshotId) {
203
+ previousSnapshot = await loadSnapshot(projectRoot, contextId, context.currentSnapshotId);
204
+ previous = await rebuildSnapshot(projectRoot, contextId, context.currentSnapshotId);
205
+ }
206
+ }
207
+ catch (error) {
208
+ if (input.contextId || !isNotFound(error))
209
+ throw error;
210
+ context = {
211
+ id: contextId,
212
+ schemaVersion: 1,
213
+ title: input.title ?? 'Untitled context',
214
+ projectRoot,
215
+ branch: git.branch,
216
+ commit: git.commit,
217
+ createdAt: new Date().toISOString(),
218
+ updatedAt: new Date().toISOString(),
219
+ lastUsedAt: null,
220
+ usageCount: 0,
221
+ status: 'draft',
222
+ currentSnapshotId: null,
223
+ tags: [],
224
+ dependencies: [],
225
+ };
226
+ }
227
+ const content = await contextFromInput(projectRoot, input, previous);
228
+ const patch = diffContext(previous, content);
229
+ if (previousSnapshot && Object.keys(patch).length === 0)
230
+ return { status: 'no_change', context, snapshot: previousSnapshot };
231
+ const minSaveIntervalMs = options.minSaveIntervalMs ?? 10_000;
232
+ const lastSavedAt = Date.parse(context.updatedAt);
233
+ if (previousSnapshot && Number.isFinite(lastSavedAt) && Date.now() - lastSavedAt < minSaveIntervalMs) {
234
+ return {
235
+ status: 'rate_limited',
236
+ context,
237
+ snapshot: previousSnapshot,
238
+ retryAt: new Date(lastSavedAt + minSaveIntervalMs).toISOString(),
239
+ };
240
+ }
241
+ const now = new Date().toISOString();
242
+ const snapshotId = createId('snap');
243
+ const mode = previousSnapshot ? 'patch' : 'baseline';
244
+ const snapshot = {
245
+ snapshotId,
246
+ schemaVersion: 1,
247
+ contextId,
248
+ parentSnapshotId: previousSnapshot?.snapshotId ?? null,
249
+ baseSnapshotId: previousSnapshot?.baseSnapshotId ?? snapshotId,
250
+ mode,
251
+ createdAt: now,
252
+ branch: git.branch,
253
+ commit: git.commit,
254
+ contentHash: hash(content),
255
+ rebuildHash: hash(content),
256
+ ...(mode === 'baseline' ? { content } : { patch }),
257
+ };
258
+ await mkdir(contextDirectory(projectRoot, contextId), { recursive: true });
259
+ await mkdir(snapshotsDirectory(projectRoot, contextId), { recursive: true });
260
+ await atomicWriteJson(snapshotPath(projectRoot, contextId, snapshotId), snapshot);
261
+ const updatedContext = {
262
+ ...context,
263
+ title: input.title ?? context.title,
264
+ branch: git.branch,
265
+ commit: git.commit,
266
+ updatedAt: now,
267
+ lastUsedAt: now,
268
+ usageCount: context.usageCount + 1,
269
+ status: 'active',
270
+ currentSnapshotId: snapshotId,
271
+ };
272
+ await atomicWriteJson(contextMetadataPath(projectRoot, contextId), updatedContext);
273
+ await updateIndex(projectRoot, {
274
+ contextId,
275
+ title: updatedContext.title,
276
+ status: updatedContext.status,
277
+ branch: updatedContext.branch,
278
+ commit: updatedContext.commit,
279
+ currentSnapshotId: snapshotId,
280
+ updatedAt: now,
281
+ });
282
+ return { status: 'saved', context: updatedContext, snapshot };
283
+ });
284
+ }
285
+ export async function resume(startPath, contextId, maxChars = 4000) {
286
+ const projectRoot = await resolveProjectRoot(startPath);
287
+ await loadManifest(projectRoot);
288
+ const git = await readGitState(projectRoot);
289
+ const index = await rebuildIndex(projectRoot);
290
+ const candidates = index.contexts
291
+ .filter((entry) => !contextId || entry.contextId === contextId)
292
+ .filter((entry) => ['active', 'stable'].includes(entry.status))
293
+ .sort((left, right) => Number(right.status === 'active') - Number(left.status === 'active') || Number(right.branch === git.branch) - Number(left.branch === git.branch) || right.updatedAt.localeCompare(left.updatedAt));
294
+ const candidate = candidates[0];
295
+ if (!candidate)
296
+ throw new Error('no active context found');
297
+ const context = await loadContext(projectRoot, candidate.contextId);
298
+ if (!context.currentSnapshotId)
299
+ throw new Error('context has no snapshot');
300
+ const snapshot = await loadSnapshot(projectRoot, candidate.contextId, context.currentSnapshotId);
301
+ const content = await rebuildSnapshot(projectRoot, candidate.contextId, snapshot.snapshotId);
302
+ const drift = context.branch !== git.branch || context.commit !== git.commit;
303
+ context.lastUsedAt = new Date().toISOString();
304
+ await atomicWriteJson(contextMetadataPath(projectRoot, context.id), context);
305
+ const budget = { remaining: Math.max(0, maxChars) };
306
+ const resumeSummary = {
307
+ progressSummary: limited(content.progressSummary, budget.remaining),
308
+ lastError: content.lastError === null ? null : limited(content.lastError, budget.remaining),
309
+ completed: limitedList(content.completed, budget),
310
+ pendingTasks: limitedList(content.pendingTasks, budget),
311
+ decisions: limitedList(content.decisions, budget),
312
+ risks: limitedList(content.risks, budget),
313
+ relatedFiles: limitedList(content.relatedFiles, budget),
314
+ branchOrCommitDrift: drift,
315
+ };
316
+ return { context, snapshot, resumeSummary, details: content };
317
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function atomicWriteJson(filePath, value) {
4
+ await mkdir(path.dirname(filePath), { recursive: true });
5
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
6
+ try {
7
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
8
+ await rename(temporaryPath, filePath);
9
+ }
10
+ finally {
11
+ await rm(temporaryPath, { force: true });
12
+ }
13
+ }
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ export class JsonStoreError extends Error {
3
+ filePath;
4
+ constructor(filePath, message) {
5
+ super(`${message}: ${filePath}`);
6
+ this.filePath = filePath;
7
+ this.name = 'JsonStoreError';
8
+ }
9
+ }
10
+ export function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ export async function readJson(filePath, validate) {
14
+ let value;
15
+ try {
16
+ value = JSON.parse(await readFile(filePath, 'utf8'));
17
+ }
18
+ catch (error) {
19
+ if (error instanceof SyntaxError)
20
+ throw new JsonStoreError(filePath, 'invalid JSON');
21
+ throw error;
22
+ }
23
+ if (validate && !validate(value))
24
+ throw new JsonStoreError(filePath, 'invalid data schema');
25
+ return value;
26
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ export function contextRoot(projectRoot) {
3
+ return path.join(projectRoot, '.context');
4
+ }
5
+ export function manifestPath(projectRoot) {
6
+ return path.join(contextRoot(projectRoot), 'manifest.json');
7
+ }
8
+ export function indexPath(projectRoot) {
9
+ return path.join(contextRoot(projectRoot), 'index.json');
10
+ }
11
+ export function ignoredPath(projectRoot) {
12
+ return path.join(projectRoot, '.contextignored');
13
+ }
14
+ export function contextsRoot(projectRoot) {
15
+ return path.join(contextRoot(projectRoot), 'contexts');
16
+ }
17
+ export function contextDirectory(projectRoot, contextId) {
18
+ return path.join(contextsRoot(projectRoot), contextId);
19
+ }
20
+ export function contextMetadataPath(projectRoot, contextId) {
21
+ return path.join(contextDirectory(projectRoot, contextId), 'context.json');
22
+ }
23
+ export function snapshotsDirectory(projectRoot, contextId) {
24
+ return path.join(contextDirectory(projectRoot, contextId), 'snaps');
25
+ }
26
+ export function snapshotPath(projectRoot, contextId, snapshotId) {
27
+ return path.join(snapshotsDirectory(projectRoot, contextId), `${snapshotId}.json`);
28
+ }
29
+ export function locksDirectory(projectRoot) {
30
+ return path.join(contextRoot(projectRoot), 'locks');
31
+ }
32
+ export function knowledgePath(projectRoot) {
33
+ return path.join(contextRoot(projectRoot), 'knowledge.json');
34
+ }
35
+ export function deadendsPath(projectRoot) {
36
+ return path.join(contextRoot(projectRoot), 'deadends.json');
37
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir, open, readFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const staleLockMs = 60_000;
4
+ async function isStale(lockPath) {
5
+ try {
6
+ const record = JSON.parse(await readFile(lockPath, 'utf8'));
7
+ const createdAt = Date.parse(record.createdAt ?? '');
8
+ return !Number.isFinite(createdAt) || Date.now() - createdAt > staleLockMs;
9
+ }
10
+ catch {
11
+ return true;
12
+ }
13
+ }
14
+ export async function withLock(lockPath, operation) {
15
+ await mkdir(path.dirname(lockPath), { recursive: true });
16
+ let handle;
17
+ try {
18
+ handle = await open(lockPath, 'wx');
19
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
20
+ }
21
+ catch {
22
+ if (await isStale(lockPath)) {
23
+ await rm(lockPath, { force: true });
24
+ handle = await open(lockPath, 'wx');
25
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
26
+ }
27
+ else {
28
+ throw new Error('context is busy; try again later');
29
+ }
30
+ }
31
+ try {
32
+ return await operation();
33
+ }
34
+ finally {
35
+ try {
36
+ await handle.close();
37
+ }
38
+ finally {
39
+ await rm(lockPath, { force: true });
40
+ }
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "fluffy-context",
3
+ "version": "0.1.0",
4
+ "description": "面向 AI 编程会话的本地上下文运行时 CLI",
5
+ "type": "module",
6
+ "keywords": [
7
+ "context-runtime",
8
+ "ai-agent",
9
+ "coding-agent",
10
+ "developer-tools",
11
+ "cli",
12
+ "context-management",
13
+ "session-resume",
14
+ "knowledge-base",
15
+ "deadend-tracking",
16
+ "snapshot"
17
+ ],
18
+ "bin": {
19
+ "ctx": "dist/src/cli/main.js"
20
+ },
21
+ "files": [
22
+ "dist/src"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20.19.0"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "test": "npm run build && node --test dist/test/*.test.js",
30
+ "ctx": "npm run build && node dist/src/cli/main.js"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.10.0",
34
+ "typescript": "^5.7.2"
35
+ }
36
+ }