fluffy-context 0.8.0 → 1.0.0-beta.1
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 +35 -0
- package/README.md +109 -613
- package/ROADMAP-1.0.md +70 -0
- package/dist/src/agent/index.d.ts +3 -0
- package/dist/src/agent/index.js +3 -0
- package/dist/src/capture/context-filter.js +2 -0
- package/dist/src/cli/main.js +50 -10
- package/dist/src/cognition/admission.d.ts +8 -0
- package/dist/src/cognition/admission.js +24 -0
- package/dist/src/cognition/import-memory.d.ts +18 -0
- package/dist/src/cognition/import-memory.js +124 -0
- package/dist/src/cognition/journal-segments.d.ts +11 -0
- package/dist/src/cognition/journal-segments.js +129 -0
- package/dist/src/cognition/journal.d.ts +13 -0
- package/dist/src/cognition/journal.js +56 -19
- package/dist/src/cognition/migration-v1.js +39 -3
- package/dist/src/cognition/runtime.js +4 -0
- package/dist/src/cognition/usage-report.js +30 -15
- package/dist/src/compiler/compile.js +29 -28
- package/dist/src/compiler/inject.d.ts +23 -0
- package/dist/src/compiler/inject.js +27 -0
- package/dist/src/hooks/claude-code.js +17 -56
- package/dist/src/integrations/claude-code.js +30 -8
- package/dist/src/mcp/server.js +26 -0
- package/dist/src/runtime/knowledge.js +57 -8
- package/dist/src/runtime/setup.d.ts +27 -0
- package/dist/src/runtime/setup.js +85 -0
- package/dist/src/storage/lock.js +19 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +4 -1
- package/skills/fluffy-context/SKILL.md +15 -5
package/ROADMAP-1.0.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# fluffy-context 1.0:可用的项目认知层
|
|
2
|
+
|
|
3
|
+
状态:第一阶段实现及三批实际使用驱动的迭代,发布候选版本 1.0.0-beta.1,稳定通道仍为 0.8.0。基线:0.8.0。目标不是保存更多对话,而是让不同 agent 在适当时刻取得可信项目知识,并留下可被下次工作验证和复用的结果。
|
|
4
|
+
|
|
5
|
+
## 产品约束
|
|
6
|
+
|
|
7
|
+
日常入口收敛到 init、inject、checkpoint、import、report。旧命令保留为诊断与治理接口,不再出现在首次使用必做步骤中。自动注入和知识验证分开:允许自动检索,不允许把出现过的文字自动升级为真。
|
|
8
|
+
|
|
9
|
+
核心协议独立于 agent:任务请求 → 有预算的证据包 → 按需展开 → 验证结果 → 项目知识。宿主适配器只负责触发和交付。AGENTS.md 是无 hook 宿主的兼容回退,不能声称保证 agent 会调用。默认语义采集;文件活动只能作为弱信号,不能替代证据。
|
|
10
|
+
|
|
11
|
+
## 六个问题的交付拆分
|
|
12
|
+
|
|
13
|
+
| 问题 | 第一阶段已实现 | 1.0 正式发布前仍需完成 |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| 初始化繁琐 | init 编排布局、首次迁移、验证、项目指引、运行时启动;保留已有禁用策略;无后台模式 | 部分迁移中断恢复、跨平台安装矩阵、配置撤销与健康摘要 |
|
|
16
|
+
| 日志快速增长 | 实体增量同步、分段 gzip 重放、观察准入预算与拒绝计数、日志/产物噪声过滤 | 遥测聚合、增量读取索引与长期增长基准 |
|
|
17
|
+
| agent 不主动使用 | 项目级明确触发指引、Claude 项目自动安装、MCP 工具描述给出触发时机、统一 API | 各宿主真实接入测试、触发覆盖率、工具选择实验;不能用 memory 提醒次数冒充接入成功 |
|
|
18
|
+
| 智能注入缺失 | inject 自动推断场景、默认关闭活动噪声、沿用来源/适用性/可信状态过滤、预算和按需展开;Claude hook 接入 | 会话内去重与增量注入、任务切换失效、冲突/新鲜度评分、多语言排序评估、真实 tokenizer |
|
|
19
|
+
| 收益不可量化 | 输入+输出成本、无 telemetry 时收益不可用、移除虚假回本日期、日志构成和现有显式复用指标 | 同任务对照实验、任务结果/测试成功率、避免重复探索的证据、provider token 接入、成本配对和置信区间 |
|
|
20
|
+
| 外部记忆无法吸收 | import 的只读预览与显式 apply,项目范围扫描、字节/条目预算、内容去重、文件行号来源、候选隔离;CLI/MCP/API | 多段结构与 front matter 解析、冲突聚类、变更追踪和撤销、额外来源适配器 |
|
|
21
|
+
|
|
22
|
+
## 数据品质与演化
|
|
23
|
+
|
|
24
|
+
候选最少具有 statement、scope、来源和内容指纹。验证时应关联真实代码或测试;失败方案应包含可复现的失败原因。原始记录数量、候选数量和被检索次数都不是知识价值。知识价值以证据有效性、适用范围、实际采用与任务结果来评价。
|
|
25
|
+
|
|
26
|
+
当前导入器采用保守的文本行抽取(20–1500 字符),每次最多 500 个候选、2000 个扫描条目、8 层目录、单文件 64 KiB、总正文 2 MiB。跳过代码围栏、标题、生成指引块、敏感内容行和符号链接,遵守 .contextignored。正则过滤不保证发现所有秘密;下一阶段需专用脱敏器。未知格式不猜测解析。重复候选不重新激活已拒绝知识。
|
|
27
|
+
|
|
28
|
+
日志是追加式权威历史,不能直接删旧事件来控制体积。现已支持保留全部事件字节、哈希与顺序的分段 gzip 归档,不依赖丢弃历史的投影快照。归档先验证链、发布临时压缩文件、解压比对,再移除原始段;双副本中断恢复与不一致拒绝已有测试。retentionMaxBytes 现在是可选观察事件的准入预算,不阻止语义事实写入,也不能宣传为全部历史的硬上限。遥测聚合和更快的增量读取仍待后续实现。
|
|
29
|
+
|
|
30
|
+
本地发布流程已落地:`npm run install:local` 从 tgz 安装,逐文件哈希核验后生成回执,恢复本项目 runtime。用户已授权每批完成后执行该流程,开发过程中优先使用全局 ctx。压缩日志需要新版读取器,旧的常驻 MCP 连接需重启后使用。
|
|
31
|
+
|
|
32
|
+
## 注入协议与触发
|
|
33
|
+
|
|
34
|
+
1. 会话开始/上下文恢复:恢复当前任务及已验证约束。
|
|
35
|
+
2. 用户提出新任务:按任务词、文件范围、分支、场景生成证据包。
|
|
36
|
+
3. 新错误出现:查询匹配的已验证死路与证据,按需展开。
|
|
37
|
+
4. 阶段结束:保存完成、待办、决策及验证情况;提取候选供后续验证。
|
|
38
|
+
|
|
39
|
+
1.0 第一阶段的场景推断是确定性关键词规则,不是模型意图理解。正文预算不包含协议包装。没有宿主事件的 agent 仍需遵循项目指引或主动调用工具;不宣称跨所有 agent 的零配置自动化。核心工具必须 fail open,初始化失败则明确报错且保留原始数据。
|
|
40
|
+
|
|
41
|
+
Claude Hook 同时修复输出契约:按照 [Claude 官方 Hook 文档](https://code.claude.com/docs/en/hooks),使用 `hookSpecificOutput.hookEventName` 和 `hookSpecificOutput.additionalContext`,不能把 additionalContext 放在顶层。回归测试覆盖两个事件名及注入字段;真实宿主中的接受与触发仍需接入验收。
|
|
42
|
+
|
|
43
|
+
运行时还修复关闭竞争:关闭时排空文件观察队列可能再次触发 touch 并重建 5 分钟闲置定时器,导致“已停止”的进程继续存活。关闭开始后不再接受活跃度刷新,并用子进程退出时限测试覆盖该行为。
|
|
44
|
+
|
|
45
|
+
## 评价体系与发布门槛
|
|
46
|
+
|
|
47
|
+
建立固定任务集:恢复旧任务、重复错误、跨文件修改、分支切换、矛盾知识、空项目。每个任务对照关闭/开启认知层,在相同仓库基线和验收测试下比较。
|
|
48
|
+
|
|
49
|
+
- 正确性优先:开启认知层不能降低测试通过率,未验证知识和失效适用范围不得进入可信知识区。
|
|
50
|
+
- 成本:分别记录模型实际输入/输出、工具返回、注入正文、写入与维护成本;不能将 JSON 字符估算当成账单。
|
|
51
|
+
- 收益:报告同任务 paired delta、检索精确率、引用采用率、错误建议率、重复探索次数和完成时间;缺数据标 unavailable。
|
|
52
|
+
- 存储:无变化同步新增事件为零;仅变化一个实体时新增一个语义事件;连续运行的增长曲线和重放耗时可复核。
|
|
53
|
+
- 初始化:新项目、0.8 项目、重复 init、禁用项目、损坏数据、路径空格/中文、Windows/macOS/Linux;原始数据不可被覆盖修复。
|
|
54
|
+
- 导入:dry run 零写入、重复 apply 零新增、路径与隐私边界、条目预算、并发 apply、候选不自动验证。
|
|
55
|
+
|
|
56
|
+
上述门槛适用于 1.0 正式版。第一阶段通过独立 beta 通道交付,不变更 latest,不代表 1.0 全部完成。
|
|
57
|
+
|
|
58
|
+
## 后续执行顺序
|
|
59
|
+
|
|
60
|
+
1. **M2 数据品质与存储**:已完成真实日志基线、分段归档、进程中断恢复、容量准入和有界知识诊断;下一步实现遥测聚合与增量读取索引。完善导入的多段解析、冲突分组与变更追踪,避免用垃圾候选填满知识库。
|
|
61
|
+
2. **M3 会话注入与宿主接入**:定义宿主 capability 声明和 session/task 身份;以内容哈希控制重复注入,任务/分支/知识状态改变时失效;测试 Claude 真实 Hook、通用 MCP 和无 Hook 的项目指引回退。优先做可测的触发可靠性,不先引入 embedding 或常驻 LLM。
|
|
62
|
+
3. **M4 效果实验与发布**:固定任务/模型/仓库基线,收集 provider usage 和测试结果;paired report 展示收益及样本数。完成跨平台 init、迁移、导入、退出和故障恢复,再发布 1.0。
|
|
63
|
+
|
|
64
|
+
2026-10-09 对本仓库的只读诊断基线:最近 30 天 42 条事件中 35 条为 workspace.paths.changed,已验证 Knowledge、Deadend 和显式复用均为 0;journal 共 1,011,281 字节。这是当时的运行快照,不是产品基准分数;后续 checkpoint 和开发会使数据改变。它说明需要优先建立语义沉淀和验证闭环。
|
|
65
|
+
|
|
66
|
+
## 本地真实验收记录
|
|
67
|
+
|
|
68
|
+
- 接入修复批:40 项测试通过,从 tarball 安装并逐文件核验 106 个文件;真实 init 发现并修复旧源码路径被误判为 MCP 冲突的问题;默认 inject 去除重复 provenance 包装。
|
|
69
|
+
- 存储治理批:114 项完整回归通过,dev.1 全局产物逐文件核验 110 个文件。真实首段从 1,050,216 字节压缩到 189,044 字节,解压后与升级前备份逐字节一致;全链 749 条事件校验通过。结果与安装回执在 `.context/builds/`,未将测试夹具写入真实项目。
|
|
70
|
+
- 检索品质批:真实 `knowledge discover --limit 1 --max-chars 450` 暴露全库成对诊断膨胀,并将不符合查询治理条件的记录带出。dev.2 让诊断使用同样过滤、保守冲突规则、候选扫描上限和正文预算,默认查询不泄漏候选或无关记录。
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * from './api.js';
|
|
2
|
+
export { injectContext } from '../compiler/inject.js';
|
|
3
|
+
export { importMemory } from '../cognition/import-memory.js';
|
|
4
|
+
export { setupProject } from '../runtime/setup.js';
|
|
2
5
|
export type { MaterializeRecipeInput, TransitionRecipeInput, } from '../cognition/recipes.js';
|
|
3
6
|
export type { MaterializeSkillInput, RecordSkillInvocationInput, RecordSkillOutcomeInput, TransitionSkillInput, } from '../cognition/skills.js';
|
|
4
7
|
export type { AcceptEvolutionCandidateInput, ProposeEvolutionInput, ProposeEvolutionResult, TransitionEvolutionProposalInput, } from '../cognition/evolution.js';
|
package/dist/src/agent/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * from './api.js';
|
|
2
|
+
export { injectContext } from '../compiler/inject.js';
|
|
3
|
+
export { importMemory } from '../cognition/import-memory.js';
|
|
4
|
+
export { setupProject } from '../runtime/setup.js';
|
|
2
5
|
export { contextCompile } from './api.js';
|
|
3
6
|
export { compileContext } from '../compiler/compile.js';
|
|
4
7
|
export { canonicalJson, contentHash, estimateTokens, rebuildManifestHash, validateManifestIdentity } from '../compiler/identity.js';
|
package/dist/src/cli/main.js
CHANGED
|
@@ -4,7 +4,7 @@ import { contextCompile, contextExpand, contextOrient, contextUsageReport, inspe
|
|
|
4
4
|
import { formatUsageReport } from '../cognition/usage-report.js';
|
|
5
5
|
import { importV1, verifyV1Import } from '../cognition/migration-v1.js';
|
|
6
6
|
import { recordUse } from '../cognition/feedback.js';
|
|
7
|
-
import { verifyJournal } from '../cognition/journal.js';
|
|
7
|
+
import { compactJournal, verifyJournal } from '../cognition/journal.js';
|
|
8
8
|
import { projectEvents } from '../cognition/projections.js';
|
|
9
9
|
import { disableRuntime, enableRuntime, ensureRuntimeForSession, runtimeStatus, serveRuntime, setFilesystemEvents, startRuntime, stopRuntime } from '../cognition/runtime.js';
|
|
10
10
|
import { loadRuntimePolicy } from '../cognition/policy.js';
|
|
@@ -13,7 +13,9 @@ import { runClaudeCodeHook } from '../hooks/claude-code.js';
|
|
|
13
13
|
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
|
-
import {
|
|
16
|
+
import { setupProject } from '../runtime/setup.js';
|
|
17
|
+
import { importMemory } from '../cognition/import-memory.js';
|
|
18
|
+
import { injectContext } from '../compiler/inject.js';
|
|
17
19
|
import { discoverKnowledge, learnKnowledge, listKnowledge, verifyKnowledge, recordDeadend, listDeadends, verifyDeadend, transitionKnowledge, transitionDeadend } from '../runtime/knowledge.js';
|
|
18
20
|
import { addNote, listActivity, listNotes } from '../runtime/notes.js';
|
|
19
21
|
import { VERSION } from '../version.js';
|
|
@@ -85,12 +87,24 @@ function graphHopsOption(args) {
|
|
|
85
87
|
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)
|
|
86
88
|
throw new Error('--budgets must be section=non-negative-integer up to 100000'); return [section, parsed]; }));
|
|
87
89
|
const HELP = {
|
|
88
|
-
init: `usage: ctx init [path] [--path <path>]
|
|
90
|
+
init: `usage: ctx init [path] [--path <path>] [--no-start] [--no-integrations]
|
|
89
91
|
|
|
90
|
-
Initialize
|
|
92
|
+
Initialize, migrate, verify, install project guidance and start the runtime. Re-running init preserves policy.
|
|
91
93
|
|
|
92
94
|
Options:
|
|
93
|
-
--path <path> Project path
|
|
95
|
+
--path <path> Project path
|
|
96
|
+
--no-start Configure without starting a background process
|
|
97
|
+
--no-integrations Skip project guidance and host configuration`,
|
|
98
|
+
import: `usage: ctx import [--path <path>] [--apply]
|
|
99
|
+
|
|
100
|
+
Preview project memory, .claude, .zcode, .cursor, .curcor, .codex and AGENTS.md.
|
|
101
|
+
--apply imports deduplicated, source-linked candidates; verification remains explicit.`,
|
|
102
|
+
inject: `usage: ctx inject [query] [--path <path>] [--max-chars <number>] [--paths <paths>] [--explain]
|
|
103
|
+
|
|
104
|
+
Select bounded task context; --explain includes detailed source provenance.`,
|
|
105
|
+
report: `usage: ctx report [options]
|
|
106
|
+
|
|
107
|
+
Alias of ctx usage report; supports its window, format and baseline options.`,
|
|
94
108
|
usage: `usage: ctx usage report [options]
|
|
95
109
|
|
|
96
110
|
Show read-only context asset, reuse, token investment, and operation metrics.
|
|
@@ -307,12 +321,16 @@ Record an idempotent use signal that affects deterministic rank only.`,
|
|
|
307
321
|
ctx skill supersede <skill-id> --rationale <text> --supersedes <entity:id> [--path <path>]
|
|
308
322
|
ctx skill invoke <skill-id> --invocation-id <id> --caller-event <id> --input-hash <hash> [--path <path>]
|
|
309
323
|
ctx skill outcome <skill-id> --invocation-id <id> --outcome-id <id> --caller-event <id> --outcome <value> --summary <text> [--path <path>]`,
|
|
310
|
-
journal: `usage: ctx journal verify [--path <path>]
|
|
324
|
+
journal: `usage: ctx journal verify|compact [--path <path>] [--apply]
|
|
311
325
|
|
|
312
326
|
Validate the v2 journal hash chain and replay projections in memory.`,
|
|
313
327
|
'journal verify': `usage: ctx journal verify [--path <path>]
|
|
314
328
|
|
|
315
329
|
Validate journal integrity without writing derived state.`,
|
|
330
|
+
'journal compact': `usage: ctx journal compact [--path <path>] [--apply]
|
|
331
|
+
|
|
332
|
+
Preview or apply lossless gzip archival. Replays the full hash chain before and after.
|
|
333
|
+
Archived projects require this development version or newer readers.`,
|
|
316
334
|
deadends: `usage: ctx deadends [--all] [--path <path>]
|
|
317
335
|
|
|
318
336
|
List recorded deadends.`,
|
|
@@ -454,7 +472,7 @@ Stop the local runtime through authenticated loopback IPC.`,
|
|
|
454
472
|
Internal runtime daemon entry point.`,
|
|
455
473
|
};
|
|
456
474
|
function usage() {
|
|
457
|
-
return `usage: ctx init|checkpoint|resume|orient|compile|expand|usage|agent|hook|integrate|status|doctor|learn|knowledge|deadend|deadends|note|activity|migrate|runtime|filesystem|feedback|skill|recipe|evolution|journal [options]
|
|
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]
|
|
458
476
|
|
|
459
477
|
Run \"ctx <command> --help\" for command details.`;
|
|
460
478
|
}
|
|
@@ -479,7 +497,7 @@ function printHelp(args) {
|
|
|
479
497
|
|| (command === 'knowledge' && ['verify', 'discover', 'deprecate', 'reject'].includes(args[1]))
|
|
480
498
|
|| (command === 'deadend' && ['verify', 'obsolete', 'reject'].includes(args[1]))
|
|
481
499
|
|| (command === 'feedback' && args[1] === 'use')
|
|
482
|
-
|| (command === 'journal' && args[1]
|
|
500
|
+
|| (command === 'journal' && ['verify', 'compact'].includes(args[1]))
|
|
483
501
|
|| (command === 'note' && ['add', 'list'].includes(args[1]))
|
|
484
502
|
|| (command === 'migrate' && ['v1', 'verify'].includes(args[1]))
|
|
485
503
|
|| (command === 'recipe' && ['list', 'inspect', 'materialize', 'verify', 'publish', 'deprecate', 'supersede'].includes(args[1]))
|
|
@@ -502,6 +520,8 @@ function validatePositionals(values, max, usageText) {
|
|
|
502
520
|
throw new Error(usageText);
|
|
503
521
|
}
|
|
504
522
|
async function run(args) {
|
|
523
|
+
if (args[0] === 'report' && !args.includes('--help'))
|
|
524
|
+
args = ['usage', 'report', ...args.slice(1)];
|
|
505
525
|
if (args.includes('--version')) {
|
|
506
526
|
print(VERSION);
|
|
507
527
|
return;
|
|
@@ -516,10 +536,24 @@ async function run(args) {
|
|
|
516
536
|
const target = option(args, '--path');
|
|
517
537
|
switch (command) {
|
|
518
538
|
case 'init': {
|
|
519
|
-
validateOptions(args.slice(1), ['--path'], ['--path']);
|
|
539
|
+
validateOptions(args.slice(1), ['--path', '--no-start', '--no-integrations'], ['--path']);
|
|
520
540
|
const values = positionals(args.slice(1), ['--path']);
|
|
521
541
|
validatePositionals(values, 1, HELP.init);
|
|
522
|
-
print(await
|
|
542
|
+
print(await setupProject(target ?? values[0], { start: !args.includes('--no-start'), integrations: !args.includes('--no-integrations') }));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
case 'import': {
|
|
546
|
+
validateOptions(args.slice(1), ['--path', '--apply'], ['--path']);
|
|
547
|
+
validatePositionals(positionals(args.slice(1), ['--path']), 0, HELP.import);
|
|
548
|
+
print(await importMemory(target, args.includes('--apply')));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
case 'inject': {
|
|
552
|
+
const valueOptions = ['--path', '--max-chars', '--paths'];
|
|
553
|
+
validateOptions(args.slice(1), [...valueOptions, '--explain'], valueOptions);
|
|
554
|
+
const values = positionals(args.slice(1), valueOptions);
|
|
555
|
+
validatePositionals(values, 1, HELP.inject);
|
|
556
|
+
print(await injectContext(target, { query: values[0], paths: listOption(args, '--paths'), maxChars: numericOption(args, '--max-chars', 2400), explain: args.includes('--explain') }, 'cli'));
|
|
523
557
|
return;
|
|
524
558
|
}
|
|
525
559
|
case 'checkpoint': {
|
|
@@ -917,6 +951,12 @@ async function run(args) {
|
|
|
917
951
|
throw new Error(HELP.recipe);
|
|
918
952
|
}
|
|
919
953
|
case 'journal': {
|
|
954
|
+
if (args[1] === 'compact') {
|
|
955
|
+
validateOptions(args.slice(2), ['--path', '--apply'], ['--path']);
|
|
956
|
+
validatePositionals(positionals(args.slice(2), ['--path']), 0, HELP['journal compact']);
|
|
957
|
+
print(await compactJournal(target, { apply: args.includes('--apply') }));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
920
960
|
if (args[1] !== 'verify')
|
|
921
961
|
throw new Error(HELP.journal);
|
|
922
962
|
validateOptions(args.slice(2), ['--path'], ['--path']);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface AdmissionStats {
|
|
2
|
+
schemaVersion: 1;
|
|
3
|
+
dropped: Record<string, number>;
|
|
4
|
+
updatedAt: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readAdmissionStats(root: string): Promise<AdmissionStats>;
|
|
7
|
+
export declare function recordAdmissionDrop(root: string, type: string): Promise<void>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { cognitionRuntimeDirectory } from '../storage/layout.js';
|
|
3
|
+
import { readJson, isRecord } from '../storage/json-store.js';
|
|
4
|
+
import { atomicWriteJson } from '../storage/atomic-write.js';
|
|
5
|
+
const statsPath = (root) => path.join(cognitionRuntimeDirectory(root), 'admission.json');
|
|
6
|
+
export async function readAdmissionStats(root) {
|
|
7
|
+
try {
|
|
8
|
+
return await readJson(statsPath(root), (value) => isRecord(value)
|
|
9
|
+
&& value.schemaVersion === 1 && typeof value.updatedAt === 'string' && isRecord(value.dropped)
|
|
10
|
+
&& Object.values(value.dropped).every((count) => typeof count === 'number' && Number.isSafeInteger(count) && count >= 0));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT')
|
|
14
|
+
return { schemaVersion: 1, dropped: {}, updatedAt: '' };
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// The journal writer lock serializes these counters too. Never store content.
|
|
19
|
+
export async function recordAdmissionDrop(root, type) {
|
|
20
|
+
const stats = await readAdmissionStats(root);
|
|
21
|
+
stats.dropped[type] = Math.min(Number.MAX_SAFE_INTEGER, (stats.dropped[type] ?? 0) + 1);
|
|
22
|
+
stats.updatedAt = new Date().toISOString();
|
|
23
|
+
await atomicWriteJson(statsPath(root), stats);
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function importMemory(startPath?: string, apply?: boolean): Promise<{
|
|
2
|
+
projectRoot: string;
|
|
3
|
+
applied: boolean;
|
|
4
|
+
scannedBytes: number;
|
|
5
|
+
results: {
|
|
6
|
+
status: string;
|
|
7
|
+
knowledgeId: string | null;
|
|
8
|
+
source: string;
|
|
9
|
+
line: number;
|
|
10
|
+
statement: string;
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
}[];
|
|
13
|
+
skipped: {
|
|
14
|
+
source: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}[];
|
|
17
|
+
trust: string;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
5
|
+
import { filterPaths } from '../capture/context-filter.js';
|
|
6
|
+
import { learnKnowledge, listKnowledge } from '../runtime/knowledge.js';
|
|
7
|
+
import { loadManifest } from '../runtime/init.js';
|
|
8
|
+
import { withLock } from '../storage/lock.js';
|
|
9
|
+
import { locksDirectory } from '../storage/layout.js';
|
|
10
|
+
const sources = ['memory', '.memory', '.claude', '.zcode', '.cursor', '.curcor', '.codex', 'AGENTS.md', 'agents.md', 'CLAUDE.md', 'MEMORY.md', '.cursorrules'];
|
|
11
|
+
const hash = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
12
|
+
const sensitive = /(?:api[_-]?key|token|password|secret)\s*[:=]\s*\S+|\b(?:sk-|sk_|ghp_|github_pat_)[A-Za-z0-9_-]+|-----BEGIN .*PRIVATE KEY-----/i;
|
|
13
|
+
export async function importMemory(startPath, apply = false) {
|
|
14
|
+
const root = await resolveProjectRoot(startPath);
|
|
15
|
+
await loadManifest(root);
|
|
16
|
+
const candidates = [];
|
|
17
|
+
const skipped = [];
|
|
18
|
+
const visited = new Set();
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
let entries = 0;
|
|
21
|
+
const canonicalRoot = await realpath(root);
|
|
22
|
+
async function scan(relative, depth = 0) {
|
|
23
|
+
if (++entries > 2000 || candidates.length >= 500 || depth > 8) {
|
|
24
|
+
skipped.push({ source: relative, reason: 'scan-budget' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const absolute = path.join(root, relative);
|
|
28
|
+
let info;
|
|
29
|
+
try {
|
|
30
|
+
info = await lstat(absolute);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT')
|
|
34
|
+
return;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
if (info.isSymbolicLink()) {
|
|
38
|
+
skipped.push({ source: relative, reason: 'symlink' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const resolved = await realpath(absolute);
|
|
42
|
+
const within = path.relative(canonicalRoot, resolved);
|
|
43
|
+
if (within.startsWith('..') || path.isAbsolute(within)) {
|
|
44
|
+
skipped.push({ source: relative, reason: 'outside-project' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
48
|
+
if (visited.has(key))
|
|
49
|
+
return;
|
|
50
|
+
visited.add(key);
|
|
51
|
+
if ((await filterPaths(root, [relative])).length === 0) {
|
|
52
|
+
skipped.push({ source: relative, reason: 'ignored' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (info.isDirectory()) {
|
|
56
|
+
for (const entry of (await readdir(absolute)).sort()) {
|
|
57
|
+
if (entries >= 2000 || candidates.length >= 500) {
|
|
58
|
+
skipped.push({ source: relative, reason: 'scan-budget' });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
await scan(path.join(relative, entry), depth + 1);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!info.isFile() || !/\.(?:md|mdc|txt)$/i.test(relative) && path.basename(relative) !== '.cursorrules')
|
|
66
|
+
return;
|
|
67
|
+
if (info.size > 64 * 1024 || bytes + info.size > 2 * 1024 * 1024) {
|
|
68
|
+
skipped.push({ source: relative, reason: 'byte-budget' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
bytes += info.size;
|
|
72
|
+
const text = await readFile(absolute, 'utf8');
|
|
73
|
+
let managed = false;
|
|
74
|
+
let code = false;
|
|
75
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
76
|
+
if (raw.includes('<!-- fluffy-context:start -->')) {
|
|
77
|
+
managed = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (raw.includes('<!-- fluffy-context:end -->')) {
|
|
81
|
+
managed = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (managed)
|
|
85
|
+
continue;
|
|
86
|
+
if (/^\s*```/.test(raw)) {
|
|
87
|
+
code = !code;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (code || /^\s*#/.test(raw))
|
|
91
|
+
continue;
|
|
92
|
+
const statement = raw.replace(/^\s*(?:[-*+] |\d+\. )/, '').trim();
|
|
93
|
+
if (statement.length < 20 || statement.length > 1500)
|
|
94
|
+
continue;
|
|
95
|
+
if (sensitive.test(statement)) {
|
|
96
|
+
skipped.push({ source: `${relative}:${index + 1}`, reason: 'potential-secret' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (candidates.length >= 500) {
|
|
100
|
+
skipped.push({ source: relative, reason: 'candidate-budget' });
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
candidates.push({ source: relative.replaceAll('\\', '/'), line: index + 1, statement,
|
|
104
|
+
fingerprint: hash(statement.replace(/\s+/g, ' ').toLowerCase()) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const source of sources)
|
|
108
|
+
await scan(source);
|
|
109
|
+
const run = async () => {
|
|
110
|
+
const known = new Set((await listKnowledge(root, true)).map((item) => hash(item.statement.replace(/\s+/g, ' ').toLowerCase())));
|
|
111
|
+
const results = [];
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
const duplicate = known.has(candidate.fingerprint);
|
|
114
|
+
known.add(candidate.fingerprint);
|
|
115
|
+
const record = apply && !duplicate ? await learnKnowledge(root, candidate.statement, {
|
|
116
|
+
kind: 'imported-memory', evidence: [`${candidate.source}:${candidate.line}`, `sha256:${candidate.fingerprint}`],
|
|
117
|
+
}) : null;
|
|
118
|
+
results.push({ ...candidate, status: duplicate ? 'duplicate' : apply ? 'candidate' : 'preview', knowledgeId: record?.knowledgeId ?? null });
|
|
119
|
+
}
|
|
120
|
+
return { projectRoot: root, applied: apply, scannedBytes: bytes, results, skipped,
|
|
121
|
+
trust: 'Imported text is unverified evidence; inspect and verify against the project before injection.' };
|
|
122
|
+
};
|
|
123
|
+
return apply ? withLock(path.join(locksDirectory(root), 'memory-import.lock'), run) : run();
|
|
124
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const JOURNAL_SEGMENT_BYTES: number;
|
|
2
|
+
export declare function journalFiles(root: string): Promise<string[]>;
|
|
3
|
+
export declare function readSegment(root: string, name: string): Promise<Buffer>;
|
|
4
|
+
export declare function archiveSegment(root: string, name: string): Promise<void>;
|
|
5
|
+
export declare function appendJournalLine(root: string, line: string): Promise<void>;
|
|
6
|
+
export declare function journalStorage(root: string, expand?: boolean): Promise<{
|
|
7
|
+
bytes: number;
|
|
8
|
+
expandedBytes: number | null;
|
|
9
|
+
archives: number;
|
|
10
|
+
segments: number;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gzip, gunzip } from 'node:zlib';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { cognitionJournalDirectory } from '../storage/layout.js';
|
|
7
|
+
const compress = promisify(gzip);
|
|
8
|
+
const decompress = promisify(gunzip);
|
|
9
|
+
export const JOURNAL_SEGMENT_BYTES = 1024 * 1024;
|
|
10
|
+
export async function journalFiles(root) {
|
|
11
|
+
let files;
|
|
12
|
+
try {
|
|
13
|
+
files = await readdir(cognitionJournalDirectory(root));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT')
|
|
17
|
+
return [];
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const segments = new Map();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (!file.endsWith('.ndjson') && !file.endsWith('.ndjson.gz'))
|
|
23
|
+
continue;
|
|
24
|
+
if (!/^\d{8}\.ndjson(?:\.gz)?$/.test(file))
|
|
25
|
+
throw new Error(`unrecognized journal segment: ${file}`);
|
|
26
|
+
const base = file.replace(/\.gz$/, '');
|
|
27
|
+
if (!segments.has(base) || file.endsWith('.gz'))
|
|
28
|
+
segments.set(base, file);
|
|
29
|
+
}
|
|
30
|
+
return [...segments.values()].sort();
|
|
31
|
+
}
|
|
32
|
+
async function optional(file) {
|
|
33
|
+
try {
|
|
34
|
+
return await readFile(file);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return null;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function readSegment(root, name) {
|
|
43
|
+
const file = path.join(cognitionJournalDirectory(root), name);
|
|
44
|
+
if (!name.endsWith('.gz')) {
|
|
45
|
+
const raw = await optional(file);
|
|
46
|
+
// An unlocked reader may race a successful archive publication.
|
|
47
|
+
return raw ?? readSegment(root, `${name}.gz`);
|
|
48
|
+
}
|
|
49
|
+
let decoded;
|
|
50
|
+
try {
|
|
51
|
+
decoded = await decompress(await readFile(file));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`invalid journal archive ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
const raw = await optional(file.slice(0, -3));
|
|
57
|
+
if (raw && !raw.equals(decoded))
|
|
58
|
+
throw new Error(`journal archive/raw mismatch: ${name}`);
|
|
59
|
+
return decoded;
|
|
60
|
+
}
|
|
61
|
+
// Caller holds cognition-journal.lock. Publish and validate before removing raw
|
|
62
|
+
// bytes; a crash with both representations is safe and validated on every read.
|
|
63
|
+
export async function archiveSegment(root, name) {
|
|
64
|
+
if (name.endsWith('.gz')) {
|
|
65
|
+
await readSegment(root, name);
|
|
66
|
+
await rm(path.join(cognitionJournalDirectory(root), name.slice(0, -3)), { force: true });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const source = path.join(cognitionJournalDirectory(root), name);
|
|
70
|
+
const raw = await readFile(source);
|
|
71
|
+
if (!raw.length)
|
|
72
|
+
return;
|
|
73
|
+
if (raw.at(-1) !== 10)
|
|
74
|
+
throw new Error(`cannot archive incomplete journal segment: ${name}`);
|
|
75
|
+
const target = `${source}.gz`;
|
|
76
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, await compress(raw, { level: 9 }), { flag: 'wx' });
|
|
79
|
+
await rename(temporary, target);
|
|
80
|
+
if (!(await readSegment(root, `${name}.gz`)).equals(raw))
|
|
81
|
+
throw new Error(`archive verification failed: ${name}`);
|
|
82
|
+
await rm(source);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await rm(temporary, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function appendJournalLine(root, line) {
|
|
89
|
+
const directory = cognitionJournalDirectory(root);
|
|
90
|
+
await mkdir(directory, { recursive: true });
|
|
91
|
+
const latest = (await journalFiles(root)).at(-1);
|
|
92
|
+
let number = latest ? Number(latest.slice(0, 8)) : 1;
|
|
93
|
+
if (latest?.endsWith('.gz'))
|
|
94
|
+
number += 1;
|
|
95
|
+
else if (latest && (await stat(path.join(directory, latest))).size >= JOURNAL_SEGMENT_BYTES) {
|
|
96
|
+
await archiveSegment(root, latest);
|
|
97
|
+
number += 1;
|
|
98
|
+
}
|
|
99
|
+
if (number > 99_999_999)
|
|
100
|
+
throw new Error('journal segment limit exceeded');
|
|
101
|
+
await appendFile(path.join(directory, `${String(number).padStart(8, '0')}.ndjson`), line, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
export async function journalStorage(root, expand = false) {
|
|
104
|
+
const files = await journalFiles(root);
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
let expandedBytes = expand ? 0 : null;
|
|
107
|
+
let archives = 0;
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
let metadata;
|
|
110
|
+
try {
|
|
111
|
+
metadata = await stat(path.join(cognitionJournalDirectory(root), file));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code === 'ENOENT' && !file.endsWith('.gz')) {
|
|
115
|
+
metadata = await stat(path.join(cognitionJournalDirectory(root), `${file}.gz`));
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
bytes += metadata.size;
|
|
121
|
+
if (file.endsWith('.gz')) {
|
|
122
|
+
archives += 1;
|
|
123
|
+
bytes += (await optional(path.join(cognitionJournalDirectory(root), file.slice(0, -3))))?.length ?? 0;
|
|
124
|
+
}
|
|
125
|
+
if (expandedBytes !== null)
|
|
126
|
+
expandedBytes += (await readSegment(root, file)).length;
|
|
127
|
+
}
|
|
128
|
+
return { bytes, expandedBytes, archives, segments: files.length };
|
|
129
|
+
}
|
|
@@ -21,6 +21,19 @@ export declare function readJournal(startPath?: string, options?: {
|
|
|
21
21
|
allowPartialTail?: boolean;
|
|
22
22
|
}): Promise<CognitionEvent[]>;
|
|
23
23
|
export declare function verifyJournal(startPath?: string): Promise<CognitionEvent[]>;
|
|
24
|
+
export declare function compactJournal(startPath?: string, options?: {
|
|
25
|
+
apply?: boolean;
|
|
26
|
+
minBytes?: number;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
applied: boolean;
|
|
29
|
+
eligible: string[];
|
|
30
|
+
events: number;
|
|
31
|
+
headHash: string | null;
|
|
32
|
+
bytesBefore: number;
|
|
33
|
+
bytesAfter: number;
|
|
34
|
+
savedBytes: number;
|
|
35
|
+
archives: number;
|
|
36
|
+
}>;
|
|
24
37
|
export declare function appendEvent<T>(startPath: string | undefined, input: AppendEventInput<T>): Promise<{
|
|
25
38
|
event: CognitionEvent<T>;
|
|
26
39
|
appended: boolean;
|