@nowcrew/daemon 0.6.99-beta.2 → 0.6.99-beta.3
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/dist/cloud-report-upload.js +1 -10
- package/dist/computer-service.js +5 -30
- package/dist/execution-journal.js +3 -1
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +4 -1
- package/dist/local-execution-prompt.js +8 -3
- package/dist/local-executor.js +64 -39
- package/dist/local-memory/commit-telemetry.js +30 -0
- package/dist/local-memory/execution-workspace.js +62 -0
- package/dist/local-memory/full-index-search.js +47 -0
- package/dist/local-memory/index-budget.js +9 -3
- package/dist/local-memory/initialization-identity.js +36 -0
- package/dist/local-memory/inventory-cli.js +52 -8
- package/dist/local-memory/inventory-source.js +15 -8
- package/dist/local-memory/inventory-v2.js +362 -0
- package/dist/local-memory/legacy-adapter.js +233 -0
- package/dist/local-memory/migration-access.js +242 -0
- package/dist/local-memory/migration-cli.js +54 -0
- package/dist/local-memory/migration-io.js +199 -0
- package/dist/local-memory/migration-pending.js +9 -0
- package/dist/local-memory/migration-plan.js +432 -0
- package/dist/local-memory/migration-schema.js +55 -0
- package/dist/local-memory/migration-state-invariants.js +65 -0
- package/dist/local-memory/migration-store.js +414 -0
- package/dist/local-memory/migration-tool-entry.js +21 -0
- package/dist/local-memory/pending-query.js +5 -2
- package/dist/local-memory/pending-schema.js +1 -1
- package/dist/local-memory/pending.js +16 -2
- package/dist/local-memory/prompt.js +21 -5
- package/dist/local-memory/read-cli.js +98 -76
- package/dist/local-memory/rollout.js +79 -11
- package/dist/local-memory/runtime.js +181 -13
- package/dist/local-memory/source-path.js +13 -0
- package/dist/local-memory/storage-format.js +249 -0
- package/dist/local-memory/write.js +189 -21
- package/dist/local-memory/writer-mode.js +13 -0
- package/dist/local-memory-diagnostics.js +96 -15
- package/dist/local-memory-telemetry.js +20 -3
- package/dist/machine-info.js +3 -0
- package/dist/marketplace-protocol.js +1 -1
- package/dist/marketplace-remote.js +1 -1
- package/dist/marketplace-runner.js +1 -1
- package/dist/memory-migration-controller.js +89 -0
- package/dist/memory-migration-inspect.js +108 -0
- package/dist/memory-migration-journal.js +14 -0
- package/dist/memory-migration-protocol.js +14 -0
- package/dist/memory-migration-runtime.js +305 -0
- package/dist/memory-prune-diagnostics.js +60 -4
- package/dist/message-file-preview.js +11 -1
- package/dist/prompt.js +9 -17
- package/dist/runner.js +1 -1
- package/dist/runtimes/claude-thread-runner.js +25 -4
- package/dist/serve.js +38 -3
- package/dist/token.js +1 -0
- package/dist/workspace-control-handler.js +16 -3
- package/dist/workspace.js +13 -2
- package/package.json +1 -1
|
@@ -2,6 +2,12 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { lstat, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { applyMemoryMigration, prepareMemoryMigration, recoverMemoryMigration } from "./migration-store.js";
|
|
6
|
+
import { MigrationStateSchema } from "./migration-schema.js";
|
|
7
|
+
import { detectLocalMemoryStorageVersion } from "./storage-format.js";
|
|
8
|
+
import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from "./initialization-identity.js";
|
|
9
|
+
import { inventory } from "./migration-io.js";
|
|
10
|
+
import { initializeExactWorkspaceSeed } from "./migration-plan.js";
|
|
5
11
|
import { applyLocalMemoryResult, parseLocalMemoryResult } from "./write.js";
|
|
6
12
|
import { verifyWorkLogSource } from "./work-log.js";
|
|
7
13
|
export function isLocalMemoryV2Eligible(input) {
|
|
@@ -12,7 +18,7 @@ export function isLocalMemoryV2Eligible(input) {
|
|
|
12
18
|
return input.enabledForHandle && input.memoryPrune;
|
|
13
19
|
}
|
|
14
20
|
const LOCAL_MEMORY_V2_MARKER = "v2-enabled";
|
|
15
|
-
|
|
21
|
+
async function writeLocalMemoryV2Marker(agentDir) {
|
|
16
22
|
const crewDir = join(agentDir, ".crew");
|
|
17
23
|
const memoryDir = join(crewDir, "memory");
|
|
18
24
|
await mkdir(crewDir, { recursive: true });
|
|
@@ -31,24 +37,151 @@ export async function initializeLocalMemoryV2(agentDir) {
|
|
|
31
37
|
throw error;
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
|
-
|
|
40
|
+
function initializedIntent(identity, state) {
|
|
41
|
+
return {
|
|
42
|
+
...identity,
|
|
43
|
+
kind: "server_write_intent",
|
|
44
|
+
sourceDigest: state.sourceDigest,
|
|
45
|
+
planDigest: state.planDigest,
|
|
46
|
+
intentVersion: 1,
|
|
47
|
+
// New-Agent initialization is derived from the exact pristine seed. Keep
|
|
48
|
+
// the receipt deterministic so a crash between intent publication and the
|
|
49
|
+
// mutable state update can be retried without creating a conflicting fact.
|
|
50
|
+
confirmedAt: "1970-01-01T00:00:00.000Z",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Initialize a pristine new Agent through the same journaled schema2
|
|
55
|
+
* publication path used by migration. The marker is written only after the
|
|
56
|
+
* committed catalog and state have both been verified.
|
|
57
|
+
*/
|
|
58
|
+
async function initializeLocalMemoryV2WithBinding(agentDir, agentId, binding) {
|
|
59
|
+
const identity = await localMemoryInitializationIdentity(agentDir, agentId, binding);
|
|
60
|
+
let state = await prepareMemoryMigration({
|
|
61
|
+
agentDir,
|
|
62
|
+
identity,
|
|
63
|
+
serverHistory: { kind: "none", isNewAgent: true },
|
|
64
|
+
});
|
|
65
|
+
if (state.origin !== "initialized")
|
|
66
|
+
throw new Error("local_memory_v2_initialization_source_invalid");
|
|
67
|
+
if (state.phase === "prepared") {
|
|
68
|
+
state = await applyMemoryMigration({
|
|
69
|
+
agentDir,
|
|
70
|
+
identity,
|
|
71
|
+
intentReceipt: initializedIntent(identity, state),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (state.phase === "applying" || state.phase === "repair_required") {
|
|
75
|
+
state = await recoverMemoryMigration({
|
|
76
|
+
agentDir,
|
|
77
|
+
identity,
|
|
78
|
+
intentReceipt: state.intentReceipt ?? initializedIntent(identity, state),
|
|
79
|
+
stoppedReceipt: {
|
|
80
|
+
kind: "migration_process_stopped",
|
|
81
|
+
migrationId: identity.migrationId,
|
|
82
|
+
machineId: identity.machineId,
|
|
83
|
+
executionId: identity.executionId,
|
|
84
|
+
ownerIdentity: identity.ownerIdentity,
|
|
85
|
+
pid: null,
|
|
86
|
+
stoppedAt: new Date().toISOString(),
|
|
87
|
+
proof: "journal_reconciled",
|
|
88
|
+
},
|
|
89
|
+
cancelled: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const memory = await readFile(join(agentDir, "MEMORY.md"), "utf8");
|
|
93
|
+
if (state.phase !== "committed" || state.origin !== "initialized" || state.storageVersion !== 2
|
|
94
|
+
|| detectLocalMemoryStorageVersion(memory) !== 2) {
|
|
95
|
+
throw new Error("local_memory_v2_initialization_incomplete");
|
|
96
|
+
}
|
|
97
|
+
await writeLocalMemoryV2Marker(agentDir);
|
|
98
|
+
}
|
|
99
|
+
/** Fresh initialization requires the identity from the authenticated ready frame. */
|
|
100
|
+
export function initializeLocalMemoryV2(agentDir, agentId, binding) {
|
|
101
|
+
return initializeLocalMemoryV2WithBinding(agentDir, agentId, binding);
|
|
102
|
+
}
|
|
103
|
+
export async function initializeLocalMemoryV2ForWorkspace(workspace, enabledForHandle, agentId, binding) {
|
|
35
104
|
const initialized = workspace.localMemoryV2Initialized === true;
|
|
36
|
-
if (
|
|
37
|
-
|| workspace.agentHomePreexisted === true || workspace.memorySeedCreated !== true)
|
|
38
|
-
return initialized;
|
|
39
|
-
try {
|
|
40
|
-
await initializeLocalMemoryV2(workspace.dir);
|
|
105
|
+
if (initialized)
|
|
41
106
|
return true;
|
|
42
|
-
|
|
43
|
-
|
|
107
|
+
// Real workspaces always report this boolean. Keep partial/mock workspace
|
|
108
|
+
// callers side-effect free instead of probing an unrelated filesystem path.
|
|
109
|
+
if (workspace.localMemoryV2Initialized !== false)
|
|
44
110
|
return false;
|
|
111
|
+
let resumable = false;
|
|
112
|
+
let effectiveBinding = binding;
|
|
113
|
+
try {
|
|
114
|
+
const state = MigrationStateSchema.parse(JSON.parse(await readFile(join(workspace.dir, ".crew", "memory", "migration.v1.json"), "utf8")));
|
|
115
|
+
const identity = await localMemoryInitializationIdentity(workspace.dir, agentId, binding);
|
|
116
|
+
const legacyIdentity = binding === undefined
|
|
117
|
+
? identity
|
|
118
|
+
: await localMemoryInitializationIdentity(workspace.dir, agentId);
|
|
119
|
+
resumable = state.origin === "initialized"
|
|
120
|
+
&& state.phase !== "rolled_back"
|
|
121
|
+
&& (isLocalMemoryInitializationIdentity(state.identity, identity)
|
|
122
|
+
|| isLocalMemoryInitializationIdentity(state.identity, legacyIdentity));
|
|
123
|
+
if (resumable && isLocalMemoryInitializationIdentity(state.identity, legacyIdentity))
|
|
124
|
+
effectiveBinding = undefined;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code !== "ENOENT")
|
|
128
|
+
throw error;
|
|
129
|
+
try {
|
|
130
|
+
const migrations = await lstat(join(workspace.dir, ".crew", "memory", "migrations"));
|
|
131
|
+
if (!migrations.isDirectory() || migrations.isSymbolicLink()) {
|
|
132
|
+
throw new Error("local_memory_v2_initialization_residue_invalid");
|
|
133
|
+
}
|
|
134
|
+
const identity = await localMemoryInitializationIdentity(workspace.dir, agentId, binding);
|
|
135
|
+
const legacyIdentity = binding === undefined
|
|
136
|
+
? identity
|
|
137
|
+
: await localMemoryInitializationIdentity(workspace.dir, agentId);
|
|
138
|
+
let operationIdentity = identity;
|
|
139
|
+
const operation = await lstat(join(workspace.dir, ".crew", "memory", "migrations", identity.migrationId))
|
|
140
|
+
.catch(async (error) => {
|
|
141
|
+
if (error.code !== "ENOENT" || identity.migrationId === legacyIdentity.migrationId)
|
|
142
|
+
throw error;
|
|
143
|
+
operationIdentity = legacyIdentity;
|
|
144
|
+
return lstat(join(workspace.dir, ".crew", "memory", "migrations", legacyIdentity.migrationId));
|
|
145
|
+
});
|
|
146
|
+
if (!operation.isDirectory() || operation.isSymbolicLink()) {
|
|
147
|
+
throw new Error("local_memory_v2_initialization_residue_invalid");
|
|
148
|
+
}
|
|
149
|
+
const current = await inventory(workspace.dir);
|
|
150
|
+
const memory = current.files.get("MEMORY.md");
|
|
151
|
+
resumable = current.files.size === 1 && memory !== undefined
|
|
152
|
+
&& current.directories.every(path => path === "notes" || path === ".crew/memory/pending")
|
|
153
|
+
&& initializeExactWorkspaceSeed(memory) !== null;
|
|
154
|
+
if (resumable && operationIdentity.migrationId === legacyIdentity.migrationId)
|
|
155
|
+
effectiveBinding = undefined;
|
|
156
|
+
}
|
|
157
|
+
catch (residueError) {
|
|
158
|
+
if (residueError.code !== "ENOENT")
|
|
159
|
+
throw residueError;
|
|
160
|
+
}
|
|
45
161
|
}
|
|
162
|
+
const pristineFirstInitialization = enabledForHandle
|
|
163
|
+
&& workspace.localMemoryV2Initialized === false
|
|
164
|
+
&& workspace.agentHomePreexisted !== true
|
|
165
|
+
&& workspace.memorySeedCreated === true;
|
|
166
|
+
if (!resumable && !pristineFirstInitialization)
|
|
167
|
+
return false;
|
|
168
|
+
// Never create another unbound sentinel. Undefined is retained solely to
|
|
169
|
+
// finish a pre-upgrade journal already proven above.
|
|
170
|
+
if (!resumable && effectiveBinding === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
await initializeLocalMemoryV2WithBinding(workspace.dir, agentId, effectiveBinding);
|
|
173
|
+
return true;
|
|
46
174
|
}
|
|
47
175
|
export function localMemoryResultPath(runDir, executionId) {
|
|
48
176
|
const suffix = createHash("sha256").update(executionId, "utf8").digest("hex").slice(0, 32);
|
|
49
177
|
return join(runDir, `.memory-result.v1-${suffix}.json`);
|
|
50
178
|
}
|
|
51
|
-
export function renderLocalMemoryResultInstructions(resultPath) {
|
|
179
|
+
export function renderLocalMemoryResultInstructions(resultPath, storageVersion = 1, migrationDeferred = false) {
|
|
180
|
+
const storagePolicy = migrationDeferred
|
|
181
|
+
? "该 Agent 的旧记忆仍在迁移审阅中。本轮只允许提交新的 create 候选;daemon 会把候选原子保存到 .crew/memory/pending/,reason=legacy_requires_migration,并提供 search-pending/read-pending 查询。候选不会进入 active,也不会自动注入普通上下文;更新、替代和 statusChanges 必须留待迁移完成后按当前 V2 ID/revision/hash 重新核对。旧 MEMORY.md、notes 和已有 active/pending 保持不变。"
|
|
182
|
+
: storageVersion === 2
|
|
183
|
+
? "完整目录保存在 MEMORY.md,模型只看到有界预览;未展示记录仍然有效。涉及旧规则时先用 crew-memory search 查询完整目录,按当前 ID/revision 更新,不因预览未命中就重复新建。20条预览不是有效记录上限;完整存储超出资源上限会拒绝提交,不得自行删除或降级旧记录。已有 pending 仍是未应用候选,须重新核对授权、重复与版本后通过 changes 申请保存,不因迁移自动生效。"
|
|
184
|
+
: "daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中。容量暂存的 create 可按需检索参考但不能覆盖现行规则;有空间的后续 proposed 提交可提升。未应用的 update/supersede 不进入普通召回,必须重新核对目标版本后处理。";
|
|
52
185
|
return `
|
|
53
186
|
|
|
54
187
|
## Structured memory prune (daemon-managed)
|
|
@@ -70,7 +203,7 @@ outcome 只允许 no_change、proposed、conflict、failed。新增、更新、
|
|
|
70
203
|
\`\`\`
|
|
71
204
|
|
|
72
205
|
每个 changes 项须保留示例中的全部字段。operation=create|update|supersede;kind=procedural|semantic|episodic|preference;priority=high|medium|low;detailCategory=preferences|project-rules|testing|debugging|git|incidents|lessons。create 的 memoryId/baseRevision/supersedesMemoryId 必须为 null;update 按现有索引填写 memoryId/baseRevision,supersedesMemoryId 为 null;supersede 填 supersedesMemoryId/baseRevision,memoryId 为 null。
|
|
73
|
-
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token
|
|
206
|
+
只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。${storagePolicy}
|
|
74
207
|
写入候选文件只表示提交候选,不能提前宣称已经记住或规则已经更新;daemon 在本轮结束后才处理,候选持久化与进入有效索引也不是同一结果。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在处理成功后清理,失败时保留日志和候选用于排查或重试。
|
|
75
208
|
`;
|
|
76
209
|
}
|
|
@@ -81,6 +214,20 @@ export function renderLocalMemoryV2WriteGuard() {
|
|
|
81
214
|
## V2 shared memory write safety
|
|
82
215
|
本节覆盖前文关于普通任务直接更新共享记忆的例外规则。本 Agent 已启用结构化本地记忆 V2。普通任务不得直接编辑共享的 $CREW_HOME/MEMORY.md 或 $CREW_HOME/notes/ 文件;即使用户明确要求记住内容,也只把候选、用户授权和分类理由记录到本任务的 $CREW_TASK_LOG,留给任务结束后的 memory-prune 结构化收尾应用。只有 memory-prune 收尾提示明确要求写入 daemon 提供的 result 文件时,才按该协议提交候选。`;
|
|
83
216
|
}
|
|
217
|
+
async function localMemoryStorageFacts(agentDir) {
|
|
218
|
+
const memory = await readFile(join(agentDir, "MEMORY.md"), "utf8");
|
|
219
|
+
const storageVersion = detectLocalMemoryStorageVersion(memory);
|
|
220
|
+
let committedState = false;
|
|
221
|
+
try {
|
|
222
|
+
const state = MigrationStateSchema.parse(JSON.parse(await readFile(join(agentDir, ".crew", "memory", "migration.v1.json"), "utf8")));
|
|
223
|
+
committedState = state.phase === "committed" && state.storageVersion === 2;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error.code !== "ENOENT" && storageVersion === 2)
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
return { storageVersion, committedState };
|
|
230
|
+
}
|
|
84
231
|
function workLogFailure(error) {
|
|
85
232
|
const reason = error instanceof Error ? error.message : "";
|
|
86
233
|
return { status: "failed", error: reason === "local_memory_work_log_source_changed"
|
|
@@ -98,6 +245,9 @@ export async function commitLocalMemoryResultAfterExit(input) {
|
|
|
98
245
|
return { status: "missing" };
|
|
99
246
|
return { status: "failed", error: error instanceof Error ? error.message : "read_failed" };
|
|
100
247
|
}
|
|
248
|
+
let storageBefore;
|
|
249
|
+
let storageAfter;
|
|
250
|
+
let applied;
|
|
101
251
|
try {
|
|
102
252
|
const result = parseLocalMemoryResult(raw);
|
|
103
253
|
if (result.outcome === "conflict" || result.outcome === "failed") {
|
|
@@ -111,12 +261,25 @@ export async function commitLocalMemoryResultAfterExit(input) {
|
|
|
111
261
|
return workLogFailure(error);
|
|
112
262
|
}
|
|
113
263
|
}
|
|
114
|
-
await
|
|
264
|
+
storageBefore = await localMemoryStorageFacts(input.agentDir);
|
|
265
|
+
if (input.requiredStorageVersion === 2
|
|
266
|
+
&& (storageBefore.storageVersion !== 2 || !storageBefore.committedState)) {
|
|
267
|
+
return { status: "failed", error: "local_memory_v2_state_invalid_before_commit" };
|
|
268
|
+
}
|
|
269
|
+
applied = await applyLocalMemoryResult({
|
|
115
270
|
agentDir: input.agentDir,
|
|
116
271
|
result,
|
|
117
272
|
executionId: input.executionId,
|
|
118
273
|
pruneTraceId: input.pruneTraceId,
|
|
274
|
+
...(input.writerCapabilities === undefined ? {} : { writerCapabilities: input.writerCapabilities }),
|
|
275
|
+
...(input.requiredStorageVersion === undefined ? {} : { requiredStorageVersion: input.requiredStorageVersion }),
|
|
276
|
+
...(input.migrationDeferredPending ? { migrationDeferredPending: true } : {}),
|
|
119
277
|
});
|
|
278
|
+
storageAfter = await localMemoryStorageFacts(input.agentDir);
|
|
279
|
+
if (input.requiredStorageVersion === 2
|
|
280
|
+
&& (storageAfter.storageVersion !== 2 || !storageAfter.committedState)) {
|
|
281
|
+
return { status: "failed", error: "local_memory_v2_state_invalid_after_commit" };
|
|
282
|
+
}
|
|
120
283
|
}
|
|
121
284
|
catch (error) {
|
|
122
285
|
return { status: "failed", error: error instanceof Error ? error.message : "apply_failed" };
|
|
@@ -147,5 +310,10 @@ export async function commitLocalMemoryResultAfterExit(input) {
|
|
|
147
310
|
catch {
|
|
148
311
|
return { status: "failed", error: "local_memory_result_cleanup_failed" };
|
|
149
312
|
}
|
|
150
|
-
return { status: "applied"
|
|
313
|
+
return { status: "applied", storageVersionBefore: storageBefore.storageVersion,
|
|
314
|
+
storageVersionAfter: storageAfter.storageVersion, committedState: storageAfter.committedState,
|
|
315
|
+
createdIds: applied.createdIds, updatedIds: applied.updatedIds,
|
|
316
|
+
statusChangedIds: applied.statusChangedIds, pendingIds: applied.pendingIds,
|
|
317
|
+
...(input.migrationDeferredPending
|
|
318
|
+
? { pendingReason: "legacy_requires_migration" } : {}) };
|
|
151
319
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files retained for audit/rollback but excluded from current memory coverage.
|
|
3
|
+
* The patterns are deliberately conservative and only recognize obvious
|
|
4
|
+
* backup/previous-version names.
|
|
5
|
+
*/
|
|
6
|
+
export function isDeferredMemoryArtifactPath(path) {
|
|
7
|
+
if (!path.startsWith("notes/"))
|
|
8
|
+
return false;
|
|
9
|
+
const name = path.slice("notes/".length);
|
|
10
|
+
return name === "MEMORY.md"
|
|
11
|
+
|| /^MEMORY\.md(?:\.bak(?:[-_.].*)?|[-_.]20\d{2}[-_]\d{2}[-_]\d{2}.*)$/iu.test(name)
|
|
12
|
+
|| /^(?!bak(?:[-_.]|$)|backup(?:[-_.]|$)|prev(?:[-_.]|$)).*(?:[-_.](?:bak|backup|prev))(?:[-_.]|$).*$/iu.test(name);
|
|
13
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { LOCAL_MEMORY_INDEX_CODE_POINT_CAP, LOCAL_MEMORY_INDEX_ENTRY_CAP, LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, renderGeneratedIndex, renderLocalMemoryIndexCard, selectLocalMemoryIndexEntries, } from "./index-budget.js";
|
|
2
|
+
export const LOCAL_MEMORY_STORAGE_BYTE_CAP = 16 * 1024 * 1024;
|
|
3
|
+
export const LOCAL_MEMORY_STORAGE_ENTRY_CAP = 10_000;
|
|
4
|
+
export const LOCAL_MEMORY_NOTE_BYTE_CAP = 32 * 1024 * 1024;
|
|
5
|
+
export const LOCAL_MEMORY_READ_BYTE_CAP = 64 * 1024 * 1024;
|
|
6
|
+
export const LOCAL_MEMORY_GENERATED_INDEX_PATTERN = /<!--\s*NOWWORK:MEMORY_INDEX:BEGIN\s+schema=[12]\s*-->[\s\S]*?<!--\s*NOWWORK:MEMORY_INDEX:END\s*-->/u;
|
|
7
|
+
const LEGACY_INDEX_HEADING = /^##[\t ]+Knowledge Index[\t ]*\r?\n/mu;
|
|
8
|
+
const LEGACY_NEXT_SECTION = /^##[\t ]+(?!#)/mu;
|
|
9
|
+
const LEGACY_CARD_HEADING = /^###[\t ]+(.+?)[\t ]*\r?$/gmu;
|
|
10
|
+
const MEMORY_PREVIEW_NOTICE = "[MEMORY.md preview truncated; use crew-memory search/read for more.]\n";
|
|
11
|
+
const length = (text) => {
|
|
12
|
+
let count = 0;
|
|
13
|
+
for (const _character of text)
|
|
14
|
+
count += 1;
|
|
15
|
+
return count;
|
|
16
|
+
};
|
|
17
|
+
function queryTerms(query) {
|
|
18
|
+
const normalized = query.normalize("NFKC").toLowerCase();
|
|
19
|
+
// 空白切词对中文混排请求只会产生一个无法命中的长 token;同时提取英文/数字
|
|
20
|
+
// 子 token,让 legacy 和 V2 预览共用同一套召回规则。
|
|
21
|
+
const whitespaceTokens = normalized.split(/\s+/u).filter(Boolean);
|
|
22
|
+
const alnumTokens = Array.from(normalized.matchAll(/[a-z0-9]{2,}/gu)).map((match) => match[0]);
|
|
23
|
+
return Array.from(new Set([...whitespaceTokens, ...alnumTokens]));
|
|
24
|
+
}
|
|
25
|
+
function fitsLegacyPreview(text, maxUnits, maxBytes) {
|
|
26
|
+
return text.length <= maxUnits && Buffer.byteLength(text, "utf8") <= maxBytes;
|
|
27
|
+
}
|
|
28
|
+
function truncateLegacyText(text, maxUnits, maxBytes) {
|
|
29
|
+
if (maxUnits <= 0 || maxBytes <= 0)
|
|
30
|
+
return "";
|
|
31
|
+
let result = "";
|
|
32
|
+
let units = 0;
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
for (const character of text) {
|
|
35
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
36
|
+
if (units + character.length > maxUnits || bytes + characterBytes > maxBytes)
|
|
37
|
+
break;
|
|
38
|
+
result += character;
|
|
39
|
+
units += character.length;
|
|
40
|
+
bytes += characterBytes;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Legacy MEMORY.md has no schema-2 generated index, so the old path used to
|
|
46
|
+
* inject only the first 6k UTF-16 units and could hide a matching late card.
|
|
47
|
+
* Keep identity/preamble intact, then select complete legacy cards by query
|
|
48
|
+
* while retaining the same UTF-16 and UTF-8 byte bounds.
|
|
49
|
+
*/
|
|
50
|
+
export function selectLegacyMemoryPreview(memory, query = "", options = {}) {
|
|
51
|
+
// Keep legacy behavior: MEMORY_INJECT_CAP historically counted UTF-16 units.
|
|
52
|
+
const maxUnits = Math.min(LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, options.maxCodePoints ?? Infinity);
|
|
53
|
+
const maxBytes = options.maxBytes ?? Infinity;
|
|
54
|
+
if (fitsLegacyPreview(memory, maxUnits, maxBytes))
|
|
55
|
+
return memory;
|
|
56
|
+
if (!fitsLegacyPreview(MEMORY_PREVIEW_NOTICE, maxUnits, maxBytes))
|
|
57
|
+
return "";
|
|
58
|
+
const heading = LEGACY_INDEX_HEADING.exec(memory);
|
|
59
|
+
if (heading?.index === undefined) {
|
|
60
|
+
const noticeBytes = Buffer.byteLength(MEMORY_PREVIEW_NOTICE, "utf8");
|
|
61
|
+
return `${MEMORY_PREVIEW_NOTICE}${truncateLegacyText(memory, maxUnits - MEMORY_PREVIEW_NOTICE.length, maxBytes - noticeBytes)}`;
|
|
62
|
+
}
|
|
63
|
+
const indexStart = heading.index + heading[0].length;
|
|
64
|
+
const rest = memory.slice(indexStart);
|
|
65
|
+
const nextSection = LEGACY_NEXT_SECTION.exec(rest);
|
|
66
|
+
const indexEnd = nextSection?.index === undefined ? memory.length : indexStart + nextSection.index;
|
|
67
|
+
const indexContent = memory.slice(indexStart, indexEnd);
|
|
68
|
+
const masked = indexContent.replace(/<!--[\s\S]*?-->/gu, (comment) => comment.replace(/[^\r\n]/gu, " "));
|
|
69
|
+
const headings = [...masked.matchAll(LEGACY_CARD_HEADING)];
|
|
70
|
+
if (headings.length === 0) {
|
|
71
|
+
const noticeBytes = Buffer.byteLength(MEMORY_PREVIEW_NOTICE, "utf8");
|
|
72
|
+
return `${MEMORY_PREVIEW_NOTICE}${truncateLegacyText(memory, maxUnits - MEMORY_PREVIEW_NOTICE.length, maxBytes - noticeBytes)}`;
|
|
73
|
+
}
|
|
74
|
+
const terms = queryTerms(query);
|
|
75
|
+
const cards = headings.map((card, index) => {
|
|
76
|
+
const start = card.index ?? 0;
|
|
77
|
+
const end = headings[index + 1]?.index ?? indexContent.length;
|
|
78
|
+
const text = indexContent.slice(start, end);
|
|
79
|
+
const searchable = text.normalize("NFKC").toLowerCase();
|
|
80
|
+
return {
|
|
81
|
+
text,
|
|
82
|
+
index,
|
|
83
|
+
relevance: terms.filter((term) => searchable.includes(term)).length,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const ranked = [...cards].sort((left, right) => right.relevance - left.relevance || left.index - right.index);
|
|
87
|
+
const firstCard = headings[0]?.index ?? 0;
|
|
88
|
+
const preamble = indexContent.slice(0, firstCard);
|
|
89
|
+
const before = memory.slice(0, indexStart);
|
|
90
|
+
const suffix = memory.slice(indexEnd);
|
|
91
|
+
const fixed = `${MEMORY_PREVIEW_NOTICE}${before}${preamble}`;
|
|
92
|
+
if (!fitsLegacyPreview(fixed, maxUnits, maxBytes)) {
|
|
93
|
+
const noticeBytes = Buffer.byteLength(MEMORY_PREVIEW_NOTICE, "utf8");
|
|
94
|
+
return `${MEMORY_PREVIEW_NOTICE}${truncateLegacyText(memory, maxUnits - MEMORY_PREVIEW_NOTICE.length, maxBytes - noticeBytes)}`;
|
|
95
|
+
}
|
|
96
|
+
let selected = fixed;
|
|
97
|
+
let selectedCount = 0;
|
|
98
|
+
for (const card of ranked) {
|
|
99
|
+
const candidate = `${selected}${card.text}`;
|
|
100
|
+
if (!fitsLegacyPreview(candidate, maxUnits, maxBytes))
|
|
101
|
+
continue;
|
|
102
|
+
selected = candidate;
|
|
103
|
+
selectedCount += 1;
|
|
104
|
+
}
|
|
105
|
+
if (selectedCount === 0)
|
|
106
|
+
return fixed;
|
|
107
|
+
const withSuffix = `${selected}${suffix}`;
|
|
108
|
+
return fitsLegacyPreview(withSuffix, maxUnits, maxBytes) ? withSuffix : selected;
|
|
109
|
+
}
|
|
110
|
+
const TYPES = new Set(["procedural", "semantic", "episodic", "preference"]);
|
|
111
|
+
const STATUSES = new Set(["candidate", "active", "pinned", "stale", "superseded", "archived"]);
|
|
112
|
+
const PRIORITIES = new Set(["high", "medium", "low"]);
|
|
113
|
+
const FIELDS = new Set(["id", "file", "type", "status", "summary", "triggers", "priority", "revision", "anchor", "updated", "review_after"]);
|
|
114
|
+
/** A malformed/unknown marker is never treated as an unmarked legacy file. */
|
|
115
|
+
export function detectLocalMemoryStorageVersion(memory) {
|
|
116
|
+
const markerCount = (memory.match(/NOWWORK:MEMORY_INDEX:/gu) ?? []).length;
|
|
117
|
+
if (markerCount === 0)
|
|
118
|
+
return 0;
|
|
119
|
+
const block = LOCAL_MEMORY_GENERATED_INDEX_PATTERN.exec(memory);
|
|
120
|
+
if (markerCount !== 2 || block?.index === undefined)
|
|
121
|
+
throw new Error("local_memory_storage_version_invalid");
|
|
122
|
+
const headings = [...memory.matchAll(/^##[\t ]+Knowledge Index[\t ]*\r?$/gmu)];
|
|
123
|
+
const heading = headings[0];
|
|
124
|
+
if (headings.length !== 1 || heading?.index === undefined)
|
|
125
|
+
throw new Error("local_memory_storage_index_invalid");
|
|
126
|
+
const sectionStart = heading.index + heading[0].length;
|
|
127
|
+
const nextSection = /^##[\t ]+(?!#)/mu.exec(memory.slice(sectionStart));
|
|
128
|
+
const sectionEnd = nextSection?.index === undefined ? memory.length : sectionStart + nextSection.index;
|
|
129
|
+
if (block.index < sectionStart || block.index + block[0].length > sectionEnd) {
|
|
130
|
+
throw new Error("local_memory_storage_index_invalid");
|
|
131
|
+
}
|
|
132
|
+
return /schema=2\s*-->/u.test(block[0].slice(0, block[0].indexOf("\n") + 1) || block[0]) ? 2 : 1;
|
|
133
|
+
}
|
|
134
|
+
function validateEntry(entry) {
|
|
135
|
+
const singleLine = (value) => !/[\r\n\u0000-\u001f\u007f]/u.test(value)
|
|
136
|
+
&& !/<!--/u.test(value);
|
|
137
|
+
if (!entry.title.trim() || !entry.summary.trim()
|
|
138
|
+
|| ![entry.title, entry.summary, entry.updated, ...entry.triggers].every(singleLine)
|
|
139
|
+
|| !/^[A-Za-z0-9_-]+$/u.test(entry.id) || entry.anchor !== entry.id
|
|
140
|
+
|| !/^notes\/(?:preferences|project-rules|testing|debugging|git|incidents|lessons)\.md$/u.test(entry.file)
|
|
141
|
+
|| !TYPES.has(entry.type) || !STATUSES.has(entry.status) || !PRIORITIES.has(entry.priority)
|
|
142
|
+
|| !Number.isSafeInteger(entry.revision) || entry.revision < 1
|
|
143
|
+
|| !entry.updated.trim()
|
|
144
|
+
|| (entry.reviewAfter !== null && !singleLine(entry.reviewAfter))) {
|
|
145
|
+
throw new Error("local_memory_storage_entry_invalid");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function validateEntries(entries) {
|
|
149
|
+
if (entries.length > LOCAL_MEMORY_STORAGE_ENTRY_CAP)
|
|
150
|
+
throw new Error("local_memory_storage_entry_limit");
|
|
151
|
+
const ids = new Set();
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
validateEntry(entry);
|
|
154
|
+
if (ids.has(entry.id))
|
|
155
|
+
throw new Error("local_memory_storage_duplicate_id");
|
|
156
|
+
ids.add(entry.id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function assertLocalMemoryStorageSize(memory) {
|
|
160
|
+
if (Buffer.byteLength(memory, "utf8") > LOCAL_MEMORY_STORAGE_BYTE_CAP)
|
|
161
|
+
throw new Error("local_memory_storage_byte_limit");
|
|
162
|
+
}
|
|
163
|
+
/** schema=2 stores every lifecycle state, unlike the schema=1 prompt projection. */
|
|
164
|
+
export function parseFullMemoryIndex(memory) {
|
|
165
|
+
assertLocalMemoryStorageSize(memory);
|
|
166
|
+
if (detectLocalMemoryStorageVersion(memory) !== 2)
|
|
167
|
+
throw new Error("local_memory_storage_version_invalid");
|
|
168
|
+
const block = LOCAL_MEMORY_GENERATED_INDEX_PATTERN.exec(memory)[0];
|
|
169
|
+
const body = block.replace(/^<!--[^>]*-->\s*/u, "").replace(/<!--[^>]*-->$/u, "");
|
|
170
|
+
const headings = [...body.matchAll(/^###[\t ]+(.+?)[\t ]*\r?$/gmu)];
|
|
171
|
+
if (headings.length > LOCAL_MEMORY_STORAGE_ENTRY_CAP)
|
|
172
|
+
throw new Error("local_memory_storage_entry_limit");
|
|
173
|
+
if (body.slice(0, headings[0]?.index ?? body.length).trim())
|
|
174
|
+
throw new Error("local_memory_storage_entry_invalid");
|
|
175
|
+
const entries = headings.map((heading, index) => {
|
|
176
|
+
const fields = {};
|
|
177
|
+
const tail = body.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? body.length);
|
|
178
|
+
for (const line of tail.split(/\r?\n/u)) {
|
|
179
|
+
if (!line.trim())
|
|
180
|
+
continue;
|
|
181
|
+
const field = /^-\s+([a-z][a-z_]*)\s*:\s*(.*?)\s*$/u.exec(line);
|
|
182
|
+
if (!field || !FIELDS.has(field[1]) || fields[field[1]] !== undefined)
|
|
183
|
+
throw new Error("local_memory_storage_entry_invalid");
|
|
184
|
+
fields[field[1]] = field[2];
|
|
185
|
+
}
|
|
186
|
+
if ([...FIELDS].filter(field => field !== "review_after").some(field => fields[field] === undefined)) {
|
|
187
|
+
throw new Error("local_memory_storage_entry_invalid");
|
|
188
|
+
}
|
|
189
|
+
if (!/^[1-9][0-9]*$/u.test(fields.revision))
|
|
190
|
+
throw new Error("local_memory_storage_entry_invalid");
|
|
191
|
+
return {
|
|
192
|
+
id: fields.id, title: heading[1], file: fields.file, type: fields.type,
|
|
193
|
+
status: fields.status, summary: fields.summary,
|
|
194
|
+
triggers: fields.triggers.split(/[,,、]/u).map(value => value.trim()).filter(Boolean),
|
|
195
|
+
priority: fields.priority, revision: Number(fields.revision),
|
|
196
|
+
anchor: fields.anchor, updated: fields.updated, reviewAfter: fields.review_after ?? null,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
validateEntries(entries);
|
|
200
|
+
const section = memory.slice(memory.indexOf("## Knowledge Index") + "## Knowledge Index".length);
|
|
201
|
+
const remaining = section.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, "").split(/^##[\t ]+(?!#)/mu)[0] ?? "";
|
|
202
|
+
if (/^###[\t ]+/mu.test(remaining.replace(/<!--[\s\S]*?-->/gu, "")))
|
|
203
|
+
throw new Error("local_memory_legacy_index_requires_migration");
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
206
|
+
export function renderFullMemoryIndex(entries) {
|
|
207
|
+
validateEntries(entries);
|
|
208
|
+
const block = `<!-- NOWWORK:MEMORY_INDEX:BEGIN schema=2 -->\n${entries.map(renderLocalMemoryIndexCard).join("\n")}<!-- NOWWORK:MEMORY_INDEX:END -->`;
|
|
209
|
+
assertLocalMemoryStorageSize(block);
|
|
210
|
+
return block;
|
|
211
|
+
}
|
|
212
|
+
export function replaceFullMemoryIndex(memory, entries) {
|
|
213
|
+
if (detectLocalMemoryStorageVersion(memory) !== 2)
|
|
214
|
+
throw new Error("local_memory_storage_version_invalid");
|
|
215
|
+
const updated = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => renderFullMemoryIndex(entries));
|
|
216
|
+
assertLocalMemoryStorageSize(updated);
|
|
217
|
+
return updated;
|
|
218
|
+
}
|
|
219
|
+
export function selectLocalMemoryPreview(memory, query = "", options = {}) {
|
|
220
|
+
const entries = parseFullMemoryIndex(memory);
|
|
221
|
+
const totalActiveEntries = entries.filter(entry => entry.status === "active" || entry.status === "pinned").length;
|
|
222
|
+
const emptyIndex = renderGeneratedIndex([]);
|
|
223
|
+
// Reserve the largest count label before selecting; all discovery text counts toward the same cap.
|
|
224
|
+
const hint = (shown) => `\nMemory preview: ${shown}/${totalActiveEntries} active entries. Use crew-memory search --query <text> and crew-memory read --file <file> for more.\n`;
|
|
225
|
+
const base = memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => `${emptyIndex}${hint(totalActiveEntries)}`);
|
|
226
|
+
const remaining = Math.min(LOCAL_MEMORY_TOTAL_CODE_POINT_CAP, options.maxCodePoints ?? Infinity) - length(base);
|
|
227
|
+
const remainingBytes = (options.maxBytes ?? Infinity) - Buffer.byteLength(base, "utf8");
|
|
228
|
+
if (remaining < 0 || remainingBytes < 0 || Number.isNaN(remaining) || Number.isNaN(remainingBytes)) {
|
|
229
|
+
throw new Error("local_memory_identity_budget_exceeded");
|
|
230
|
+
}
|
|
231
|
+
const terms = queryTerms(query);
|
|
232
|
+
const selection = selectLocalMemoryIndexEntries(entries, {
|
|
233
|
+
entryCap: LOCAL_MEMORY_INDEX_ENTRY_CAP,
|
|
234
|
+
indexCodePointCap: Math.min(LOCAL_MEMORY_INDEX_CODE_POINT_CAP - length(emptyIndex), remaining),
|
|
235
|
+
indexByteCap: remainingBytes,
|
|
236
|
+
}, {
|
|
237
|
+
protectedIds: new Set(entries.filter(entry => entry.status === "pinned").map(entry => entry.id)),
|
|
238
|
+
relevance: entry => {
|
|
239
|
+
const searchable = `${entry.title} ${entry.summary} ${entry.triggers.join(" ")}`.normalize("NFKC").toLowerCase();
|
|
240
|
+
return terms.filter(term => searchable.includes(term)).length;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (selection.reason === "index_over_budget_pinned")
|
|
244
|
+
throw new Error("local_memory_pinned_budget_exceeded");
|
|
245
|
+
return {
|
|
246
|
+
...selection, totalActiveEntries,
|
|
247
|
+
memory: memory.replace(LOCAL_MEMORY_GENERATED_INDEX_PATTERN, () => `${renderGeneratedIndex(selection.entries)}${hint(selection.entries.length)}`),
|
|
248
|
+
};
|
|
249
|
+
}
|