fluffy-context 1.0.0-beta.1 → 1.0.0-beta.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # 发布说明
2
2
 
3
+ ## 1.0.0-beta.2
4
+
5
+ - 新增 `ctx upgrade`:已有项目数据预检、带 SHA-256 清单的流式备份、v1 导入或状态对齐、完整日志无损归档、投影重建及历史哈希验证。
6
+ - `--dry-run` 不写数据;默认向 stderr 输出按实际字节、记录和日志段计数的阶段进度,stdout 保持 JSON;`--no-progress` 可关闭进度。
7
+ - 成功后恢复原运行时状态,保留禁用策略;失败保留备份及现场。支持原始数据未改变时恢复缺失回执的中断导入。
8
+ - 备份排除旧备份、开发产物、锁及运行时临时凭据;拒绝符号链接,避免向项目外读写。压缩不丢弃事件,也不自动提升知识可信状态。
9
+
10
+ 升级本地命令:`npm install --global fluffy-context@1.0.0-beta.2`。关闭目标项目的 agent/MCP 会话后,运行 `ctx upgrade --dry-run` 预览,再运行 `ctx upgrade` 执行。备份会额外占用磁盘;当前全链校验仍需将事件加载到内存,数十个分支的长期真实项目验收仍待完成。
11
+
3
12
  ## 1.0.0-beta.1
4
13
 
5
14
  这是项目认知层的首个 beta,面向愿意验证新工作流的用户;npm `latest` 保持 0.8.0。本版本不承诺所有 agent 都会自动调用工具,也不将字符估算宣传为真实 token 节省。
package/README.md CHANGED
@@ -6,7 +6,9 @@
6
6
 
7
7
  它是 LLM 与项目之间的一层本地记忆:保存工作进度,积累经过验证的项目知识,按当前任务提供有长度预算的上下文。CLI 命令是 `ctx`,也提供 MCP 和 JavaScript / TypeScript API。
8
8
 
9
- > 本文对应 **1.0.0-beta.1**。稳定通道仍为 0.8.0;以下新工作流需要安装 beta。[升级说明与已知限制](CHANGELOG.md) · [1.0 后续计划](ROADMAP-1.0.md)
9
+ > 本文对应 **1.0.0-beta.2**。稳定通道仍为 0.8.0;以下新工作流需要安装 beta。[升级说明与已知限制](CHANGELOG.md) · [1.0 后续计划](ROADMAP-1.0.md)
10
+
11
+ **beta.2 新增 `ctx upgrade`**,用于已有项目的数据升级与日志归档;beta.1 不含这个命令。
10
12
 
11
13
  ## 它能帮你做什么
12
14
 
@@ -58,6 +60,23 @@ ctx checkpoint --title "支付回调修复" --progress "已定位重复回调导
58
60
 
59
61
  这些命令返回 JSON,方便 agent 和脚本读取。你也可以直接在终端使用。
60
62
 
63
+ ## 已有项目怎样升级
64
+
65
+ 使用包含 `upgrade` 的新版 ctx,先关闭这个项目的 agent/MCP 会话,然后在项目目录执行:
66
+
67
+ ```sh
68
+ ctx upgrade --dry-run # 只检查数据和备份大小,不写入、不停运行时
69
+ ctx upgrade # 备份、迁移、归档、验证
70
+ ```
71
+
72
+ 其它项目可用 `ctx upgrade --path /path/to/project`。终端显示各阶段进度:备份按实际字节、迁移按记录、日志校验和压缩按段计数;百分比表示当前阶段,不是剩余时间预测。脚本可用 `--no-progress` 关闭进度,stdout 始终返回 JSON。
73
+
74
+ 升级保留任务、知识状态和完整事件历史,支持仅有旧 Context 数据的项目,也支持已有认知日志的 0.8.0 项目。运行时升级前会停止,成功后恢复原来的运行状态;禁用策略保持不变。
75
+
76
+ 备份保存在 `.context/backups/upgrade-*/`,包含 `data/` 原文件、`backup.json` 哈希清单和成功后的 `upgrade.json` 回执。备份不包含旧备份、开发打包产物、锁和运行时临时凭据。**备份会额外占用磁盘,日志压缩节省量不包含备份体积。**
77
+
78
+ 重复升级不会重复导入相同数据,但每次执行都会创建独立备份。校验失败时会报错并保留现场,不删除历史、不自动覆盖还原;关闭其它写入者后可以重试。压缩后的日志不能与 0.8.0 混用,完成后用新版 ctx 重新连接 agent。
79
+
61
80
  ## 怎样接入你的 AI 编程工具
62
81
 
63
82
  接入后,理想的日常流程是:**任务开始取上下文 → 开发与验证 → 阶段结束存进度**。不需要人每轮对话都操作 ctx,但 agent 是否会触发取决于宿主的接入方式。
package/ROADMAP-1.0.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # fluffy-context 1.0:可用的项目认知层
2
2
 
3
- 状态:第一阶段实现及三批实际使用驱动的迭代,发布候选版本 1.0.0-beta.1,稳定通道仍为 0.8.0。基线:0.8.0。目标不是保存更多对话,而是让不同 agent 在适当时刻取得可信项目知识,并留下可被下次工作验证和复用的结果。
3
+ 状态:第一阶段实现及三批实际使用驱动的迭代,当前版本 1.0.0-beta.2,稳定通道仍为 0.8.0。基线:0.8.0。目标不是保存更多对话,而是让不同 agent 在适当时刻取得可信项目知识,并留下可被下次工作验证和复用的结果。
4
4
 
5
5
  ## 产品约束
6
6
 
@@ -68,3 +68,7 @@ Claude Hook 同时修复输出契约:按照 [Claude 官方 Hook 文档](https:
68
68
  - 接入修复批:40 项测试通过,从 tarball 安装并逐文件核验 106 个文件;真实 init 发现并修复旧源码路径被误判为 MCP 冲突的问题;默认 inject 去除重复 provenance 包装。
69
69
  - 存储治理批:114 项完整回归通过,dev.1 全局产物逐文件核验 110 个文件。真实首段从 1,050,216 字节压缩到 189,044 字节,解压后与升级前备份逐字节一致;全链 749 条事件校验通过。结果与安装回执在 `.context/builds/`,未将测试夹具写入真实项目。
70
70
  - 检索品质批:真实 `knowledge discover --limit 1 --max-chars 450` 暴露全库成对诊断膨胀,并将不符合查询治理条件的记录带出。dev.2 让诊断使用同样过滤、保守冲突规则、候选扫描上限和正文预算,默认查询不泄漏候选或无关记录。
71
+
72
+ ## beta.2 升级入口
73
+
74
+ 新增 `ctx upgrade`,提供只读预览、流式哈希备份、数据迁移与无损日志归档、阶段进度和运行时状态恢复。缺失导入回执且原始数据未变时可以重试;更复杂的损坏恢复仍需人工检查。已有项目不需要手动串联底层迁移与归档命令。
@@ -2,6 +2,7 @@ export * from './api.js';
2
2
  export { injectContext } from '../compiler/inject.js';
3
3
  export { importMemory } from '../cognition/import-memory.js';
4
4
  export { setupProject } from '../runtime/setup.js';
5
+ export { upgradeProject } from '../runtime/upgrade.js';
5
6
  export type { MaterializeRecipeInput, TransitionRecipeInput, } from '../cognition/recipes.js';
6
7
  export type { MaterializeSkillInput, RecordSkillInvocationInput, RecordSkillOutcomeInput, TransitionSkillInput, } from '../cognition/skills.js';
7
8
  export type { AcceptEvolutionCandidateInput, ProposeEvolutionInput, ProposeEvolutionResult, TransitionEvolutionProposalInput, } from '../cognition/evolution.js';
@@ -2,6 +2,7 @@ export * from './api.js';
2
2
  export { injectContext } from '../compiler/inject.js';
3
3
  export { importMemory } from '../cognition/import-memory.js';
4
4
  export { setupProject } from '../runtime/setup.js';
5
+ export { upgradeProject } from '../runtime/upgrade.js';
5
6
  export { contextCompile } from './api.js';
6
7
  export { compileContext } from '../compiler/compile.js';
7
8
  export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
@@ -14,6 +14,8 @@ import { readGitWorktreeState } from '../git/git-adapter.js';
14
14
  import { inspectClaudeIntegration, installClaudeIntegration } from '../integrations/claude-code.js';
15
15
  import { doctor, status } from '../runtime/diagnostics.js';
16
16
  import { setupProject } from '../runtime/setup.js';
17
+ import { upgradeProject } from '../runtime/upgrade.js';
18
+ import { consoleProgress } from './progress.js';
17
19
  import { importMemory } from '../cognition/import-memory.js';
18
20
  import { injectContext } from '../compiler/inject.js';
19
21
  import { discoverKnowledge, learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend, transitionKnowledge, transitionDeadend } from '../runtime/knowledge.js';
@@ -87,6 +89,12 @@ function graphHopsOption(args) {
87
89
  const recipeBudgets = (value) => value === undefined ? undefined : Object.fromEntries(value.split(',').map((item) => { const [section, budget] = item.split('=', 2); const parsed = Number(budget); if (!section || !Number.isSafeInteger(parsed) || parsed < 0 || parsed > 100_000)
88
90
  throw new Error('--budgets must be section=non-negative-integer up to 100000'); return [section, parsed]; }));
89
91
  const HELP = {
92
+ upgrade: `usage: ctx upgrade [--path <path>] [--dry-run] [--no-progress]
93
+
94
+ Back up and upgrade existing project data to the 1.0 beta layout, preserving all history.
95
+ Close other agent/MCP sessions first. Progress goes to stderr; stdout remains JSON.
96
+ --dry-run validates and previews without writing or stopping the runtime.
97
+ --no-progress suppresses progress output. The prior runtime running state is restored on success.`,
90
98
  init: `usage: ctx init [path] [--path <path>] [--no-start] [--no-integrations]
91
99
 
92
100
  Initialize, migrate, verify, install project guidance and start the runtime. Re-running init preserves policy.
@@ -472,7 +480,7 @@ Stop the local runtime through authenticated loopback IPC.`,
472
480
  Internal runtime daemon entry point.`,
473
481
  };
474
482
  function usage() {
475
- return `usage: ctx init|inject|import|report|checkpoint|resume|orient|compile|expand|usage|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|skill|recipe|evolution|journal [options]
483
+ return `usage: ctx init|upgrade|inject|import|report|checkpoint|resume|orient|compile|expand|usage|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|skill|recipe|evolution|journal [options]
476
484
 
477
485
  Run \"ctx <command> --help\" for command details.`;
478
486
  }
@@ -535,6 +543,19 @@ async function run(args) {
535
543
  const command = args[0];
536
544
  const target = option(args, '--path');
537
545
  switch (command) {
546
+ case 'upgrade': {
547
+ validateOptions(args.slice(1), ['--path', '--dry-run', '--no-progress'], ['--path']);
548
+ validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.upgrade);
549
+ try {
550
+ print(await upgradeProject(target, { dryRun: args.includes('--dry-run'), onProgress: args.includes('--no-progress') ? undefined : consoleProgress() }));
551
+ }
552
+ catch (error) {
553
+ if (process.stderr.isTTY && !args.includes('--no-progress'))
554
+ process.stderr.write('\n');
555
+ throw error;
556
+ }
557
+ return;
558
+ }
538
559
  case 'init': {
539
560
  validateOptions(args.slice(1), ['--path', '--no-start', '--no-integrations'], ['--path']);
540
561
  const values = positionals(args.slice(1), ['--path']);
@@ -0,0 +1,2 @@
1
+ import type { ProgressReporter } from '../cognition/progress.js';
2
+ export declare function consoleProgress(stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>): ProgressReporter;
@@ -0,0 +1,21 @@
1
+ export function consoleProgress(stream = process.stderr) {
2
+ let lastPhase = '';
3
+ let lastBucket = -1;
4
+ let lastWrite = 0;
5
+ return ({ phase, completed, total, unit = '' }) => {
6
+ const ratio = total === null ? null : total === 0 ? 1 : Math.min(1, completed / total);
7
+ const bucket = ratio === null ? -1 : Math.floor(ratio * 10);
8
+ const changed = phase !== lastPhase;
9
+ if (!changed && bucket === lastBucket && (ratio !== 1 || lastBucket === 10))
10
+ return;
11
+ if (!changed && ratio !== 1 && Date.now() - lastWrite < 100)
12
+ return;
13
+ const filled = ratio === null ? 0 : Math.floor(ratio * 20);
14
+ const bar = ratio === null ? '····················' : '#'.repeat(filled) + '-'.repeat(20 - filled);
15
+ const value = ratio === null ? 'working' : `${Math.floor(ratio * 100)}% ${completed}/${total} ${unit}`;
16
+ stream.write(`${stream.isTTY ? '\r\x1b[2K' : ''}[${bar}] ${phase} ${value}${stream.isTTY && phase !== 'complete' && phase !== 'preview-complete' ? '' : '\n'}`);
17
+ lastPhase = phase;
18
+ lastBucket = bucket;
19
+ lastWrite = Date.now();
20
+ };
21
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CognitionEvent, CognitionEventSource, CognitionEventType, CognitionGitAnchor } from './types.js';
2
+ import type { ProgressReporter } from './progress.js';
2
3
  export interface AppendEventInput<T = Record<string, unknown>> {
3
4
  eventId?: string;
4
5
  type: CognitionEventType;
@@ -19,11 +20,13 @@ export interface AppendEventInput<T = Record<string, unknown>> {
19
20
  export declare function eventHash<T>(event: Omit<CognitionEvent<T>, 'hash'>): string;
20
21
  export declare function readJournal(startPath?: string, options?: {
21
22
  allowPartialTail?: boolean;
23
+ onProgress?: ProgressReporter;
22
24
  }): Promise<CognitionEvent[]>;
23
25
  export declare function verifyJournal(startPath?: string): Promise<CognitionEvent[]>;
24
26
  export declare function compactJournal(startPath?: string, options?: {
25
27
  apply?: boolean;
26
28
  minBytes?: number;
29
+ onProgress?: ProgressReporter;
27
30
  }): Promise<{
28
31
  applied: boolean;
29
32
  eligible: string[];
@@ -258,6 +258,8 @@ export async function readJournal(startPath, options = {}) {
258
258
  const events = [];
259
259
  let previousHash = null;
260
260
  const files = await journalFiles(projectRoot);
261
+ options.onProgress?.({ phase: 'verify-journal', completed: 0, total: files.length, unit: 'segments' });
262
+ let completed = 0;
261
263
  for (const file of files) {
262
264
  const content = (await readSegment(projectRoot, file)).toString('utf8');
263
265
  const lines = content.split('\n');
@@ -283,6 +285,7 @@ export async function readJournal(startPath, options = {}) {
283
285
  previousHash = event.hash;
284
286
  events.push(event);
285
287
  }
288
+ options.onProgress?.({ phase: 'verify-journal', completed: ++completed, total: files.length, unit: 'segments' });
286
289
  }
287
290
  return events;
288
291
  }
@@ -295,7 +298,7 @@ export async function compactJournal(startPath, options = {}) {
295
298
  if (!Number.isSafeInteger(minBytes) || minBytes < 0)
296
299
  throw new Error('minBytes must be a non-negative integer');
297
300
  const operation = async () => {
298
- const events = await verifyJournal(root);
301
+ const events = await readJournal(root, { allowPartialTail: false, onProgress: options.onProgress });
299
302
  const before = await journalStorage(root);
300
303
  const eligible = [];
301
304
  for (const file of await journalFiles(root)) {
@@ -303,13 +306,16 @@ export async function compactJournal(startPath, options = {}) {
303
306
  eligible.push(file);
304
307
  }
305
308
  if (options.apply) {
306
- for (const file of eligible)
309
+ options.onProgress?.({ phase: 'archive', completed: 0, total: eligible.length, unit: 'segments' });
310
+ for (const [index, file] of eligible.entries()) {
307
311
  await archiveSegment(root, file);
312
+ options.onProgress?.({ phase: 'archive', completed: index + 1, total: eligible.length, unit: 'segments' });
313
+ }
308
314
  // Complete deletion after an interrupted publication, but only after validation.
309
315
  for (const file of await journalFiles(root))
310
316
  if (file.endsWith('.gz'))
311
317
  await archiveSegment(root, file);
312
- const replay = await verifyJournal(root);
318
+ const replay = await readJournal(root, { allowPartialTail: false, onProgress: options.onProgress });
313
319
  if (replay.length !== events.length || replay.at(-1)?.hash !== events.at(-1)?.hash)
314
320
  throw new Error('journal replay changed during compaction');
315
321
  }
@@ -1,5 +1,6 @@
1
+ import type { ProgressReporter } from './progress.js';
1
2
  import { type MigrationReport } from './types.js';
2
- export declare function importV1(startPath?: string): Promise<MigrationReport>;
3
+ export declare function importV1(startPath?: string, onProgress?: ProgressReporter): Promise<MigrationReport>;
3
4
  export declare function reconcileV1State(startPath?: string): Promise<void>;
4
5
  export declare function verifyV1Import(startPath?: string): Promise<{
5
6
  verified: boolean;
@@ -41,13 +41,14 @@ async function readOptional(filePath) {
41
41
  throw error;
42
42
  }
43
43
  }
44
- async function sourceRecords(projectRoot) {
44
+ async function sourceRecords(projectRoot, onProgress) {
45
45
  const sources = [];
46
46
  const records = [];
47
47
  const snapshotAnchors = new Map();
48
48
  const v1ManifestPath = manifestPath(projectRoot);
49
49
  sources.push({ filePath: v1ManifestPath, content: await readFile(v1ManifestPath, 'utf8') });
50
50
  const contextIds = (await readdir(contextsRoot(projectRoot))).sort();
51
+ onProgress?.({ phase: 'scan-contexts', completed: 0, total: contextIds.length, unit: 'contexts' });
51
52
  for (const contextId of contextIds) {
52
53
  const contextFile = contextMetadataPath(projectRoot, contextId);
53
54
  const contextContent = await readFile(contextFile, 'utf8');
@@ -73,6 +74,7 @@ async function sourceRecords(projectRoot) {
73
74
  sourceHash: sha256(content),
74
75
  });
75
76
  }
77
+ onProgress?.({ phase: 'scan-contexts', completed: contextIds.indexOf(contextId) + 1, total: contextIds.length, unit: 'contexts' });
76
78
  }
77
79
  const noteFile = path.join(projectRoot, '.context', 'notes.json');
78
80
  const knowledgeFile = path.join(projectRoot, '.context', 'knowledge.json');
@@ -106,7 +108,11 @@ async function writeV2Metadata(projectRoot) {
106
108
  const v1 = await loadManifest(projectRoot);
107
109
  const manifest = { schemaVersion: 2, layoutVersion: COGNITION_LAYOUT_VERSION, projectId: v1.projectId, createdAt: new Date().toISOString(), policyVersion: 1 };
108
110
  const policy = { schemaVersion: 2, enabled: true, autoStart: true, allowHookStartup: true, inactivityTimeoutMs: 300_000, promptIntentMaxChars: 500, filesystemEventsEnabled: false, retentionMaxBytes: 64 * 1024 * 1024 };
109
- await Promise.all([atomicWriteJson(cognitionManifestPath(projectRoot), manifest), atomicWriteJson(cognitionPolicyPath(projectRoot), policy)]);
111
+ // Interrupted imports must not replace an existing project's opt-out policy.
112
+ if (await readOptional(cognitionManifestPath(projectRoot)) === null)
113
+ await atomicWriteJson(cognitionManifestPath(projectRoot), manifest);
114
+ if (await readOptional(cognitionPolicyPath(projectRoot)) === null)
115
+ await atomicWriteJson(cognitionPolicyPath(projectRoot), policy);
110
116
  }
111
117
  async function quarantineReadiness(projectRoot, readiness) {
112
118
  await mkdir(cognitionQuarantineDirectory(projectRoot), { recursive: true });
@@ -117,7 +123,7 @@ async function quarantineReadiness(projectRoot, readiness) {
117
123
  issues: readiness.issues,
118
124
  });
119
125
  }
120
- export async function importV1(startPath) {
126
+ export async function importV1(startPath, onProgress) {
121
127
  const projectRoot = await resolveProjectRoot(startPath);
122
128
  return withLock(`${locksDirectory(projectRoot)}/cognition-migration.lock`, async () => {
123
129
  const readiness = await migrationReadiness(projectRoot);
@@ -126,7 +132,7 @@ export async function importV1(startPath) {
126
132
  await quarantineReadiness(projectRoot, report);
127
133
  return report;
128
134
  }
129
- const { records, sourceHash } = await sourceRecords(projectRoot);
135
+ const { records, sourceHash } = await sourceRecords(projectRoot, onProgress);
130
136
  const existingReceipt = await readOptional(cognitionV1ImportPath(projectRoot));
131
137
  if (existingReceipt) {
132
138
  const receipt = JSON.parse(existingReceipt);
@@ -138,6 +144,7 @@ export async function importV1(startPath) {
138
144
  }
139
145
  await writeV2Metadata(projectRoot);
140
146
  const importedEventIds = [];
147
+ onProgress?.({ phase: 'import-records', completed: 0, total: records.length, unit: 'records' });
141
148
  for (const record of records) {
142
149
  const event = await appendEvent(projectRoot, {
143
150
  type: eventType(record),
@@ -148,6 +155,7 @@ export async function importV1(startPath) {
148
155
  payload: { record: record.record, sourcePath: record.sourcePath, sourceHash: record.sourceHash, anchorProvenance: record.git.branch === null && record.git.head === null ? 'global' : 'snapshot' },
149
156
  });
150
157
  importedEventIds.push(event.event.eventId);
158
+ onProgress?.({ phase: 'import-records', completed: importedEventIds.length, total: records.length, unit: 'records' });
151
159
  }
152
160
  await rebuildProjections(projectRoot);
153
161
  await atomicWriteJson(cognitionV1SyncPath(projectRoot), { schemaVersion: 2, fingerprint: await v1Fingerprint(projectRoot), updatedAt: new Date().toISOString() });
@@ -0,0 +1,7 @@
1
+ export interface ProgressUpdate {
2
+ phase: string;
3
+ completed: number;
4
+ total: number | null;
5
+ unit?: string;
6
+ }
7
+ export type ProgressReporter = (update: ProgressUpdate) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,3 @@
1
1
  import type { MigrationReadiness } from './types.js';
2
- export declare function migrationReadiness(startPath?: string): Promise<MigrationReadiness>;
2
+ import type { ProgressReporter } from './progress.js';
3
+ export declare function migrationReadiness(startPath?: string, onProgress?: ProgressReporter): Promise<MigrationReadiness>;
@@ -14,7 +14,7 @@ async function exists(filePath) {
14
14
  return false;
15
15
  }
16
16
  }
17
- export async function migrationReadiness(startPath) {
17
+ export async function migrationReadiness(startPath, onProgress) {
18
18
  const projectRoot = await resolveProjectRoot(startPath);
19
19
  const counts = { contexts: 0, snapshots: 0, notes: 0, knowledge: 0, deadends: 0 };
20
20
  const issues = [];
@@ -40,7 +40,8 @@ export async function migrationReadiness(startPath) {
40
40
  catch (error) {
41
41
  issues.push(`v1 contexts: ${error instanceof Error ? error.message : 'unavailable'}`);
42
42
  }
43
- for (const contextId of contextIds) {
43
+ onProgress?.({ phase: 'inspect-contexts', completed: 0, total: contextIds.length, unit: 'contexts' });
44
+ for (const [index, contextId] of contextIds.entries()) {
44
45
  try {
45
46
  const context = await readJson(contextMetadataPath(projectRoot, contextId));
46
47
  if (context.schemaVersion !== 1 || context.id !== contextId || context.projectRoot !== projectRoot) {
@@ -58,6 +59,7 @@ export async function migrationReadiness(startPath) {
58
59
  catch (error) {
59
60
  issues.push(`context ${contextId}: ${error instanceof Error ? error.message : 'invalid'}`);
60
61
  }
62
+ onProgress?.({ phase: 'inspect-contexts', completed: index + 1, total: contextIds.length, unit: 'contexts' });
61
63
  }
62
64
  try {
63
65
  counts.notes = (await listNotes(projectRoot, { limit: Number.MAX_SAFE_INTEGER, maxChars: Number.MAX_SAFE_INTEGER })).length;
@@ -0,0 +1,63 @@
1
+ import type { ProgressReporter } from '../cognition/progress.js';
2
+ export declare function upgradeProject(startPath?: string, options?: {
3
+ dryRun?: boolean;
4
+ onProgress?: ProgressReporter;
5
+ }): Promise<{
6
+ runtime: import("../cognition/types.js").RuntimeStatus;
7
+ restartRuntime: boolean;
8
+ applied: boolean;
9
+ backup: {
10
+ directory: string;
11
+ files: number;
12
+ bytes: number;
13
+ };
14
+ journal: {
15
+ verified: boolean;
16
+ eventCount: number;
17
+ addedEvents: number;
18
+ };
19
+ storage: {
20
+ applied: boolean;
21
+ eligible: string[];
22
+ events: number;
23
+ headHash: string | null;
24
+ bytesBefore: number;
25
+ bytesAfter: number;
26
+ savedBytes: number;
27
+ archives: number;
28
+ };
29
+ projectRoot: string;
30
+ targetVersion: string;
31
+ counts: {
32
+ contexts: number;
33
+ snapshots: number;
34
+ notes: number;
35
+ knowledge: number;
36
+ deadends: number;
37
+ };
38
+ existingEvents: number;
39
+ migration: string;
40
+ backupBytes: number;
41
+ warning: string;
42
+ } | {
43
+ applied: boolean;
44
+ projectRoot: string;
45
+ targetVersion: string;
46
+ counts: {
47
+ contexts: number;
48
+ snapshots: number;
49
+ notes: number;
50
+ knowledge: number;
51
+ deadends: number;
52
+ };
53
+ existingEvents: number;
54
+ migration: string;
55
+ backupBytes: number;
56
+ storage: {
57
+ bytes: number;
58
+ expandedBytes: number | null;
59
+ archives: number;
60
+ segments: number;
61
+ };
62
+ warning: string;
63
+ }>;
@@ -0,0 +1,175 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { lstat, mkdir, readFile, readdir } from 'node:fs/promises';
4
+ import { Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import path from 'node:path';
7
+ import { resolveProjectRoot } from '../project/project-resolver.js';
8
+ import { atomicWriteJson } from '../storage/atomic-write.js';
9
+ import { cognitionManifestPath, cognitionV1ImportPath, contextRoot, locksDirectory } from '../storage/layout.js';
10
+ import { withLock } from '../storage/lock.js';
11
+ import { loadManifest } from './init.js';
12
+ import { migrationReadiness } from '../cognition/readiness.js';
13
+ import { importV1, reconcileV1State, verifyV1Import } from '../cognition/migration-v1.js';
14
+ import { compactJournal, readJournal } from '../cognition/journal.js';
15
+ import { journalStorage } from '../cognition/journal-segments.js';
16
+ import { loadRuntimePolicy } from '../cognition/policy.js';
17
+ import { runtimeStatus, startRuntime, stopRuntime } from '../cognition/runtime.js';
18
+ import { rebuildProjections } from '../cognition/projections.js';
19
+ import { v1Synced } from '../cognition/v1-bridge.js';
20
+ import { COGNITION_LAYOUT_VERSION } from '../cognition/types.js';
21
+ import { VERSION } from '../version.js';
22
+ const excluded = new Set(['backups', 'builds', 'locks', 'cognition/runtime/lease.json', 'cognition/runtime/secret', 'cognition/runtime/startup.json']);
23
+ async function exists(file) {
24
+ try {
25
+ await lstat(file);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ if (error.code === 'ENOENT')
30
+ return false;
31
+ throw error;
32
+ }
33
+ }
34
+ async function inventory(root) {
35
+ const files = [];
36
+ async function visit(relative) {
37
+ if (excluded.has(relative))
38
+ return;
39
+ const file = path.join(root, relative);
40
+ const stat = await lstat(file);
41
+ if (stat.isSymbolicLink())
42
+ throw new Error(`upgrade refuses symlink: ${relative || '.context'}`);
43
+ if (stat.isDirectory()) {
44
+ for (const name of (await readdir(file)).sort())
45
+ await visit(relative ? `${relative}/${name}` : name);
46
+ }
47
+ else if (stat.isFile())
48
+ files.push({ relative, bytes: stat.size });
49
+ else
50
+ throw new Error(`upgrade refuses unsupported file: ${relative}`);
51
+ }
52
+ await visit('');
53
+ return files;
54
+ }
55
+ async function hashFile(file) {
56
+ const hash = createHash('sha256');
57
+ for await (const chunk of createReadStream(file))
58
+ hash.update(chunk);
59
+ return hash.digest('hex');
60
+ }
61
+ async function backup(root, destination, progress) {
62
+ const files = await inventory(root);
63
+ const total = files.reduce((sum, file) => sum + file.bytes, 0);
64
+ const manifest = [];
65
+ let completed = 0;
66
+ progress?.({ phase: 'backup', completed, total, unit: 'bytes' });
67
+ for (const file of files) {
68
+ const source = path.join(root, file.relative);
69
+ const target = path.join(destination, 'data', file.relative);
70
+ await mkdir(path.dirname(target), { recursive: true });
71
+ const hash = createHash('sha256');
72
+ let bytes = 0;
73
+ await pipeline(createReadStream(source), new Transform({ transform(chunk, _encoding, callback) {
74
+ hash.update(chunk);
75
+ bytes += chunk.length;
76
+ completed += chunk.length;
77
+ progress?.({ phase: 'backup', completed, total, unit: 'bytes' });
78
+ callback(null, chunk);
79
+ } }), createWriteStream(target, { flags: 'wx' }));
80
+ const sha256 = hash.digest('hex');
81
+ if (bytes !== file.bytes || await hashFile(target) !== sha256 || await hashFile(source) !== sha256)
82
+ throw new Error(`backup changed during copy: ${file.relative}`);
83
+ manifest.push({ ...file, sha256 });
84
+ }
85
+ if (JSON.stringify(await inventory(root)) !== JSON.stringify(files))
86
+ throw new Error('project data changed during backup; close other agents and retry');
87
+ await atomicWriteJson(path.join(destination, 'backup.json'), { version: VERSION, completedAt: new Date().toISOString(), excluded: [...excluded], files: manifest });
88
+ return { directory: destination, files: files.length, bytes: total };
89
+ }
90
+ async function locked(root, names, action) {
91
+ if (!names.length)
92
+ return action();
93
+ return withLock(path.join(locksDirectory(root), `${names[0]}.lock`), () => locked(root, names.slice(1), action));
94
+ }
95
+ export async function upgradeProject(startPath, options = {}) {
96
+ const root = await resolveProjectRoot(startPath);
97
+ const progress = options.onProgress;
98
+ progress?.({ phase: 'inspect', completed: 0, total: null });
99
+ // Read-only preflight; never initialize a missing or invalid legacy store.
100
+ const files = await inventory(contextRoot(root));
101
+ const manifest = await loadManifest(root);
102
+ const readiness = await migrationReadiness(root, progress);
103
+ if (!readiness.eligible)
104
+ throw new Error(`upgrade preflight failed: ${readiness.issues.join('; ')}`);
105
+ if (readiness.v2Present) {
106
+ const cognition = JSON.parse(await readFile(cognitionManifestPath(root), 'utf8'));
107
+ if (cognition.schemaVersion !== 2 || cognition.layoutVersion !== COGNITION_LAYOUT_VERSION || cognition.projectId !== manifest.projectId)
108
+ throw new Error('unsupported cognition manifest; upgrade refused');
109
+ await loadRuntimePolicy(root);
110
+ }
111
+ const initial = await readJournal(root, { allowPartialTail: false, onProgress: progress });
112
+ const storage = await journalStorage(root);
113
+ const receiptPresent = await exists(cognitionV1ImportPath(root));
114
+ if (receiptPresent) {
115
+ const receipt = JSON.parse(await readFile(cognitionV1ImportPath(root), 'utf8'));
116
+ if (!readiness.v2Present || receipt.schemaVersion !== 2 || typeof receipt.sourceHash !== 'string' || !Array.isArray(receipt.eventIds))
117
+ throw new Error('invalid migration receipt; upgrade refused');
118
+ }
119
+ const plan = { projectRoot: root, targetVersion: VERSION, counts: readiness.counts, existingEvents: initial.length,
120
+ migration: receiptPresent ? 'reconcile' : readiness.v2Present ? 'resume-import' : 'import',
121
+ backupBytes: files.reduce((sum, file) => sum + file.bytes, 0), storage,
122
+ warning: 'Close all agent/MCP sessions before applying. Archived journals cannot be opened with 0.8.0.' };
123
+ if (options.dryRun) {
124
+ progress?.({ phase: 'preview-complete', completed: 1, total: 1 });
125
+ return { ...plan, applied: false };
126
+ }
127
+ return locked(root, ['upgrade', 'cognition-runtime-start'], async () => {
128
+ const wasRunning = (await runtimeStatus(root)).running;
129
+ progress?.({ phase: 'stop-runtime', completed: 0, total: null });
130
+ if (wasRunning)
131
+ await stopRuntime(root);
132
+ let savedBackup = null;
133
+ let result;
134
+ try {
135
+ result = await locked(root, ['setup', 'checkpoint', 'knowledge', 'deadends', 'notes'], async () => {
136
+ const backups = path.join(contextRoot(root), 'backups');
137
+ if (await exists(backups) && (await lstat(backups)).isSymbolicLink())
138
+ throw new Error('upgrade refuses symlinked backup directory');
139
+ const destination = path.join(backups, `upgrade-${Date.now()}-${randomUUID()}`);
140
+ savedBackup = await locked(root, ['cognition-migration', 'cognition-journal'], () => backup(contextRoot(root), destination, progress));
141
+ const before = await readJournal(root, { allowPartialTail: false, onProgress: progress });
142
+ progress?.({ phase: 'migrate', completed: 0, total: null });
143
+ if (!receiptPresent) {
144
+ const migration = await importV1(root, progress);
145
+ if (!migration.applied)
146
+ throw new Error(migration.issues.join('; '));
147
+ const verification = await verifyV1Import(root);
148
+ if (!verification.verified)
149
+ throw new Error(verification.issues.join('; '));
150
+ }
151
+ else if (!await v1Synced(root))
152
+ await reconcileV1State(root);
153
+ const compacted = await compactJournal(root, { apply: true, onProgress: progress });
154
+ progress?.({ phase: 'rebuild-projections', completed: 0, total: null });
155
+ await rebuildProjections(root);
156
+ const after = await readJournal(root, { allowPartialTail: false, onProgress: progress });
157
+ if (after.length < before.length || before.some((event, index) => after[index].hash !== event.hash))
158
+ throw new Error('upgrade changed historical journal events');
159
+ const complete = { ...plan, applied: true, backup: savedBackup, journal: { verified: true, eventCount: after.length, addedEvents: after.length - before.length }, storage: compacted };
160
+ await atomicWriteJson(path.join(destination, 'upgrade.json'), { ...complete, completedAt: new Date().toISOString() });
161
+ return complete;
162
+ });
163
+ }
164
+ catch (error) {
165
+ throw new Error(`upgrade failed; runtime remains stopped. ${savedBackup ? `Backup: ${savedBackup.directory}. ` : 'No completed backup. '} ${error instanceof Error ? error.message : String(error)}`);
166
+ }
167
+ // The startup lock must be released before launching a new runtime.
168
+ return { ...result, restartRuntime: wasRunning };
169
+ }).then(async (result) => {
170
+ progress?.({ phase: 'restore-runtime', completed: 0, total: null });
171
+ const runtime = result.restartRuntime ? await startRuntime(root) : await runtimeStatus(root);
172
+ progress?.({ phase: 'complete', completed: 1, total: 1 });
173
+ return { ...result, runtime };
174
+ });
175
+ }
@@ -1 +1 @@
1
- export declare const VERSION = "1.0.0-beta.1";
1
+ export declare const VERSION = "1.0.0-beta.2";
@@ -1 +1 @@
1
- export const VERSION = '1.0.0-beta.1';
1
+ export const VERSION = '1.0.0-beta.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluffy-context",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Local context management CLI and MCP tools for AI coding agents",
5
5
  "license": "MIT",
6
6
  "author": "FluffyChi-Xing",
@@ -10,7 +10,9 @@ compatibility: 需要 Node.js >=20.19.0;Git 可选。CLI 通过 npm 全局安
10
10
 
11
11
  ## 使用原则
12
12
 
13
- - 当前 beta 为 1.0.0-beta.1。每批通过测试后执行 `npm run install:local` 从 tarball 更新全局 ctx,查看 `.context/builds/install-*.json` 哈希核验回执。不要以源码链接安装代替这一验收。
13
+ - `1.0.0-beta.2` 新增 `ctx upgrade`,用于已有项目数据升级。先关闭其它 agent/MCP 写入会话;用 `--dry-run` 只读检查,再执行 `ctx upgrade` 自动备份、迁移、压缩和验证。阶段进度在 stderr,stdout 为 JSON,可用 `--no-progress` 关闭进度。备份在 `.context/backups/upgrade-*`;重复执行不重复导入,但会新增备份。已发布 beta.1 不包含此命令。
14
+
15
+ - 当前 beta 为 1.0.0-beta.2。每批通过测试后执行 `npm run install:local` 从 tarball 更新全局 ctx,查看 `.context/builds/install-*.json` 哈希核验回执。不要以源码链接安装代替这一验收。
14
16
  - knowledge discover 的诊断只覆盖符合当前状态、scope 和 query 的记录,最多 10 组;不要将 possibleConflicts 当成已确认矛盾。默认查询不能通过诊断字段泄漏候选内容。
15
17
  - `ctx journal compact` 默认预览;`--apply` 保留完整事件和哈希链进行 gzip 归档。约 1 MiB 的段也会在追加或 init 时自动维护。归档项目不能再用 0.8.0 读取或写入,常驻 MCP 连接升级后需要重启。
16
18
  - `retentionMaxBytes` 限制可选观察事件,不限制重要语义写入;在 report 查看 admission.dropped。默认 inject 只返回精简证据包,需要候选来源详情时使用 `--explain`。