@nowcrew/daemon 0.6.88-beta.8 → 0.6.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,26 +242,26 @@ The stable adapters keep prompts out of provider argv wherever the provider prot
242
242
 
243
243
  | Runtime | Transport | System/wake input | Native resume |
244
244
  | --- | --- | --- | --- |
245
- | Claude | stream-json CLI | daemon-owned system prompt file | CLI session id |
246
- | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume` |
247
- | Kimi | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
248
- | Hermes | ACP over stdio | `session/prompt` | `session/resume` (`session/load` compatibility fallback) |
249
- | OpenCode | `run --format json` | stdin | `--session` |
245
+ | Claude | stream-json CLI | daemon-owned system prompt file + user input | CLI session id; thread-bound: matching native SessionStart proof + SDK initialization |
246
+ | Codex | app-server JSON-RPC over stdio | `developerInstructions` + turn input | `thread/resume`; thread-bound: returned id must match |
247
+ | Kimi | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
248
+ | Hermes | ACP over stdio | `session/prompt` | unbound: `session/resume` (`session/load` fallback); thread-bound: cold |
249
+ | OpenCode | `run --format json` | stdin | unbound: `--session`; thread-bound: cold |
250
250
  | DeepSeek Harness | ACP over stdio | `session/prompt` | none (fresh session per execution) |
251
251
 
252
252
  Kimi ACP currently requires a Kimi account even when the CLI has a working custom provider. Only for
253
253
  the explicit `Authentication required` response, the adapter retries once through Kimi's stream-json
254
- CLI and carries the same native session id; unrelated ACP failures stay failures. The CLI fallback is
254
+ CLI; only unbound executions can carry a saved native session id. Unrelated ACP failures stay failures. The CLI fallback is
255
255
  subject to the Windows UTF-16 argv guard, while protocol-v1 itself remains disabled on Windows until
256
256
  durable Job Object ownership is available.
257
257
 
258
258
  Hermes uses only ACP capabilities advertised by the installed CLI. Model and effort choices come from
259
259
  a short-lived discovery session; credentials, provider profiles, plugins, and `HERMES_HOME` remain
260
- machine-local. A missing resumed ACP session is retried once as a fresh session.
260
+ machine-local. For unbound executions, a missing resumed ACP session is retried once as a fresh session.
261
261
 
262
262
  OpenCode receives the prompt through stdin and runs with both cwd and `PWD` anchored to the Agent
263
263
  workspace. Models and variants come from `opencode models --verbose` (with the plain catalog as a
264
- compatibility fallback). A missing resumed session is retried once without `--session`; malformed,
264
+ compatibility fallback). For unbound executions, a missing resumed session is retried once without `--session`; malformed,
265
265
  empty, incomplete, or explicitly failed JSON streams remain failures. NowWork never writes
266
266
  `opencode.json` or replaces `OPENCODE_CONFIG_CONTENT`. The daemon advertises OpenCode as executable
267
267
  only when `opencode run --help` exposes `--format`, `--dangerously-skip-permissions`, `--dir`,
@@ -291,8 +291,12 @@ uses fresh sessions and committed text; it does not claim resume, images, MCP, r
291
291
  or transcript support. Agent environment variables cannot override `DEEPSEEK_*`, `DSH_*`, or
292
292
  `NOWCREW_DEEPSEEK_HARNESS_CONFIG`; those values always come from the daemon service environment.
293
293
 
294
- Codex, Kimi, Hermes, and OpenCode sessions use the same bounded per-task persistence and keyed lease as Claude when they
295
- run through protocol v1. A first-progress watchdog covers a provider that accepts a turn but remains
294
+ Unbound Codex, Kimi, Hermes, and OpenCode sessions retain the same configured warm/budget policy and keyed
295
+ lease as Claude through protocol v1. Thread-bound Claude/Codex sessions keep their native session across
296
+ warm-cache expiry, token estimates, and turn counts; verified restoration receives only accepted-cursor
297
+ deltas. New sessions and runtimes without verified restoration receive complete current-thread history
298
+ before the business turn. See the [thread context contract](../docs/thread-scoped-native-context.md).
299
+ A first-progress watchdog covers a provider that accepts a turn but remains
296
300
  semantically silent; after the first semantic event, the execution's configured total timeout remains
297
301
  authoritative so a legitimate long-running tool is not killed for quiet output.
298
302
  DeepSeek Harness is execution-v1-only and intentionally skips native session persistence.
@@ -17,7 +17,9 @@ function truncateUtf8(value, maxBytes) {
17
17
  return output;
18
18
  }
19
19
  export function extractIncomingMessage(wakePrompt) {
20
- const match = wakePrompt.match(/(?:^|\n)(?:Incoming message|来信):[ \t]*(.*?)(?=\n(?:Start with crew|先用 crew)|$)/su);
20
+ // Keep legacy wake delimiters while accepting program-delivered-context prompts.
21
+ // Stop before policy/history blocks; neither is the current user's message.
22
+ const match = wakePrompt.match(/(?:^|\n)(?:Incoming message|来信):[ \t]*(.*?)(?=\n(?:Start with crew|先用 crew|Work directly from this thread's context|直接依据程序交付的本线程上下文|No thread anchor:|没有线程锚点[,,])|$)/su);
21
23
  const content = match?.[1]?.trim();
22
24
  return content ? content : null;
23
25
  }
@@ -200,7 +200,7 @@ export const ExecutionStartSchema = z.object({
200
200
  language: z.enum(["zh", "en"]),
201
201
  systemPrompt: z.string(),
202
202
  wakePrompt: z.string(),
203
- maintenancePurpose: z.literal("self_training").optional(),
203
+ maintenancePurpose: z.enum(["self_training", "memory_prune"]).optional(),
204
204
  }).strict(),
205
205
  runtime: z.object({
206
206
  name: RuntimeSchema,
@@ -844,6 +844,7 @@ export async function runExecution(config, input, dependencies) {
844
844
  executionId: spec.executionId,
845
845
  handle: spec.agent.handle,
846
846
  channelId: spec.context.channelId,
847
+ ...(spec.context.threadId === undefined ? {} : { threadId: spec.context.threadId }),
847
848
  keyMode: "opaque",
848
849
  taskKey: spec.workspace.taskKey,
849
850
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
@@ -885,13 +886,13 @@ export async function runExecution(config, input, dependencies) {
885
886
  } : {}),
886
887
  },
887
888
  session: {
888
- enabled: selfTraining ? false : config.resume,
889
+ enabled: spec.instructions.maintenancePurpose !== undefined ? false : config.resume,
889
890
  warmMs: config.resumeWarmMs,
890
891
  budgetTokens: config.sessionBudgetTokens,
891
892
  softTokens: config.sessionSoftTokens,
892
893
  maxTurns: config.sessionMaxTurns,
893
894
  },
894
- ...(selfTraining ? { maintenancePurpose: "self_training" } : {}),
895
+ ...(spec.instructions.maintenancePurpose === undefined ? {} : { maintenancePurpose: spec.instructions.maintenancePurpose }),
895
896
  localMemoryV2Enabled,
896
897
  };
897
898
  const result = await Promise.race([
@@ -15,6 +15,8 @@ import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report
15
15
  import { reportDeliveryContext } from "./cloud-report-receipt.js";
16
16
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
17
17
  import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
18
+ import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
19
+ import { prepareThreadDelivery } from "./thread-input-delivery.js";
18
20
  import { providerErrorFromExit } from "./provider-error.js";
19
21
  import { createConsoleFormatter } from "./console-formatter.js";
20
22
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -108,6 +110,9 @@ async function launchLegacyRuntime(request) {
108
110
  if (request.runtime === "deepseek-harness") {
109
111
  throw new Error("DeepSeek Harness requires execution protocol v1");
110
112
  }
113
+ if (request.runtime !== "claude")
114
+ request = { ...request, wakePrompt: (await prepareThreadDelivery({ env: request.env,
115
+ resumed: false, sessionId: "unverified-cold-legacy", wakePrompt: request.wakePrompt })).prompt };
111
116
  const common = {
112
117
  bin: request.bin,
113
118
  cwd: request.cwd,
@@ -175,6 +180,7 @@ async function withKeyedLease(key, operation, cancellation) {
175
180
  }
176
181
  }
177
182
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
183
+ input = isolateMaintenanceSession(input);
178
184
  const versionedProjectSkills = input.projectSkills !== undefined
179
185
  && !Array.isArray(input.projectSkills);
180
186
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -255,6 +261,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
255
261
  let localMemoryResultPath = null;
256
262
  let executionWorkspace = workspace;
257
263
  let localMemoryTelemetry = null;
264
+ let nativeThreadContext = null;
258
265
  const inheritedEnv = { ...process.env };
259
266
  const reportRoots = {
260
267
  root: workspace.dir,
@@ -294,12 +301,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
294
301
  : null;
295
302
  const currentPrior = storedCurrentPrior !== null
296
303
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
304
+ && storedCurrentPrior.runtime === runtime.name
305
+ && storedCurrentPrior.threadRoot === input.resumeKey
297
306
  ? storedCurrentPrior
298
307
  : null;
299
- // Interactive V1 uses collision-resistant session directories, while the
300
- // older legacy path stored metadata under tasks/<safe thread key>. Import
301
- // that metadata once so an existing thread can cross the protocol boundary
302
- // without losing its native runtime conversation.
308
+ // Import compatible legacy metadata across the protocol boundary.
303
309
  const legacyRunDir = input.session.enabled
304
310
  && supportsNativeResume
305
311
  && input.keyMode === "opaque"
@@ -315,7 +321,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
315
321
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
316
322
  ? null
317
323
  : await readSession(legacyRunDir);
318
- const prior = currentPrior ?? legacyPrior;
324
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
325
+ ? legacyPrior : null;
326
+ const prior = currentPrior ?? compatibleLegacyPrior;
319
327
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
320
328
  const usesLegacyCwd = legacyRunDir !== null
321
329
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -336,11 +344,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
336
344
  ? prior?.model ?? currentModel
337
345
  : currentModel;
338
346
  const resumeSessionId = supportsNativeResume && sessionExists
339
- ? pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, input.session.budgetTokens, input.session.maxTurns, providerFp)
347
+ ? pickResumeId(input.resumeKey && (!Number.isSafeInteger(prior?.threadContextSeq) || prior?.threadHistoryComplete !== true) ? null : prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, input.resumeKey ? 0 : input.session.budgetTokens, input.resumeKey ? 0 : input.session.maxTurns, providerFp)
340
348
  : null;
341
349
  const resuming = resumeSessionId !== null;
342
350
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
343
- && pickResumeId(prior, Date.now(), input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
351
+ && pickResumeId(prior, Date.now(), input.resumeKey ? 0 : input.session.warmMs, resumeComparisonModel, 0, 0, providerFp) !== null;
344
352
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
345
353
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
346
354
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -467,6 +475,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
467
475
  }
468
476
  memoryPruneFailurePhase = "runtime_prepare";
469
477
  const crewHome = isolatedMaintenance ? executionWorkspace.runDir : workspace.dir;
478
+ const contextThreadId = input.threadId ?? input.resumeKey;
479
+ if (contextThreadId && input.maintenancePurpose === undefined) {
480
+ nativeThreadContext = new NativeThreadContext(join(executionWorkspace.runDir, `.crew-context-${input.executionId}.json`), contextThreadId, runtime.name, resumeSessionId, prior);
481
+ await nativeThreadContext.initialize();
482
+ }
470
483
  const baseEnv = {
471
484
  ...inheritedEnv,
472
485
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -479,6 +492,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
479
492
  CREW_TOKEN: input.launch.token,
480
493
  CREW_CHANNEL: input.channelId,
481
494
  CREW_HOME: crewHome,
495
+ // Empty values override any stale inherited hints from an enclosing runtime.
496
+ CREW_THREAD_ROOT: contextThreadId ?? "",
497
+ CREW_THREAD_CONTEXT_FILE: nativeThreadContext?.path ?? "",
482
498
  // Keep the daemon-selected storage root authoritative for runtimes.
483
499
  // In profile-backed NowWork sessions the daemon config may differ from
484
500
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -803,6 +819,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
803
819
  const event = parseLine(line);
804
820
  if (!event)
805
821
  return;
822
+ nativeThreadContext?.observe(event);
806
823
  localMemoryTelemetry?.observe(event);
807
824
  if (isRuntimeReadyEvent(runtime.name, event))
808
825
  markRuntimeReady();
@@ -961,9 +978,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
961
978
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
962
979
  : (resuming ? prior?.contextTokens : undefined);
963
980
  await writeSession(workspace.sessionDir, {
981
+ runtime: runtime.name,
982
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
983
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
964
984
  sessionId,
965
985
  lastRunAt: Date.now(),
966
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
986
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
967
987
  model: currentModel,
968
988
  providerFingerprint: providerFp,
969
989
  ...(input.sessionContextFingerprint === undefined
@@ -997,7 +1017,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
997
1017
  ...(usage === undefined ? {} : { usage }),
998
1018
  model: observedModel,
999
1019
  runtime: runtime.name,
1000
- resumed: resuming,
1020
+ resumed: nativeThreadContext?.confirmed ?? false,
1001
1021
  sessionId,
1002
1022
  errorMessage: errorTail || null,
1003
1023
  ...(providerError === undefined ? {} : { providerError }),
@@ -1009,6 +1029,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1009
1029
  };
1010
1030
  }
1011
1031
  finally {
1032
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1012
1033
  startupReservationState.current?.release();
1013
1034
  try {
1014
1035
  await localMemoryTelemetry?.finish();
@@ -1,11 +1,34 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, realpath, rename, rm, writeFile, readFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
- import { isRecallablePending, parsePendingMemoryCandidate } from "./pending-schema.js";
5
- import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
6
- import { pendingRecallScore } from "./pending-match.js";
4
+ import { z } from "zod";
7
5
  export const LOCAL_MEMORY_PENDING_SCAN_FILE_CAP = 128;
8
6
  export const LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP = 1_000_000;
7
+ const CandidateSchema = z.object({
8
+ operation: z.enum(["create", "update", "supersede"]),
9
+ memoryId: z.string().nullable(),
10
+ baseRevision: z.number().int().nonnegative().nullable(),
11
+ supersedesMemoryId: z.string().nullable(),
12
+ title: z.string().min(1),
13
+ kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
14
+ summary: z.string().min(1),
15
+ triggers: z.array(z.string()).min(1),
16
+ priority: z.enum(["high", "medium", "low"]),
17
+ detailCategory: z.enum(["preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons"]),
18
+ detailBody: z.string().min(1),
19
+ reviewAfter: z.string().nullable(),
20
+ }).strict();
21
+ const PendingSchema = z.object({
22
+ schemaVersion: z.literal(1),
23
+ pendingId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
24
+ createdAt: z.string().min(1),
25
+ reason: z.literal("index_over_budget"),
26
+ source: z.object({
27
+ executionId: z.string().min(1),
28
+ pruneTraceId: z.string().min(1).nullable(),
29
+ }).strict(),
30
+ candidate: CandidateSchema,
31
+ }).strict();
9
32
  function within(root, candidate) {
10
33
  const path = relative(root, candidate);
11
34
  return path === "" || (path !== ".." && !path.startsWith(`..${sep}`));
@@ -99,11 +122,13 @@ async function atomicWrite(path, content) {
99
122
  }
100
123
  }
101
124
  export async function persistPendingMemoryCandidate(input) {
102
- const parsed = parsePendingMemoryCandidate(input.candidate);
125
+ const parsed = PendingSchema.safeParse(input.candidate);
126
+ if (!parsed.success)
127
+ throw new Error("local_memory_pending_invalid");
103
128
  const directory = await pendingDirectory(input.agentDir);
104
- const target = join(directory, `${parsed.pendingId}.json`);
129
+ const target = join(directory, `${parsed.data.pendingId}.json`);
105
130
  await assertRegularFile(target);
106
- await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
131
+ await atomicWrite(target, `${JSON.stringify(parsed.data, null, 2)}\n`);
107
132
  return target;
108
133
  }
109
134
  export async function removePendingMemoryCandidate(input) {
@@ -115,44 +140,73 @@ export async function removePendingMemoryCandidate(input) {
115
140
  await rm(target, { force: true });
116
141
  }
117
142
  export async function readPendingMemoryCandidates(input) {
118
- const { directory } = await pendingReadDirectory(input.agentDir).catch((error) => {
119
- if (error instanceof Error && error.message === "memory_pending_path_invalid") {
120
- throw new Error("local_memory_pending_path_invalid");
121
- }
122
- throw error;
123
- });
124
- const names = await pendingFileNames(directory);
143
+ const directory = await existingPendingDirectory(input.agentDir);
144
+ if (directory === null)
145
+ return [];
146
+ const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort();
125
147
  const maxFiles = Math.max(0, input.maxFiles ?? LOCAL_MEMORY_PENDING_SCAN_FILE_CAP);
126
148
  const maxBytes = Math.max(0, input.maxBytes ?? LOCAL_MEMORY_PENDING_SCAN_BYTE_CAP);
127
149
  const result = [];
128
150
  let scannedBytes = 0;
129
- for (const name of names.slice(0, maxFiles)) {
130
- if (scannedBytes >= maxBytes)
151
+ for (const name of names) {
152
+ if (result.length >= maxFiles || scannedBytes >= maxBytes)
131
153
  break;
154
+ const path = join(directory, name);
132
155
  try {
133
- const source = await readPendingSource(input.agentDir, name, maxBytes - scannedBytes);
134
- scannedBytes += source.bytes;
135
- result.push(source.candidate);
156
+ await assertRegularFile(path);
157
+ const stat = await lstat(path);
158
+ if (!stat.isFile() || stat.isSymbolicLink())
159
+ throw new Error("local_memory_pending_path_invalid");
160
+ if (scannedBytes + stat.size > maxBytes)
161
+ break;
162
+ const raw = await readFile(path, "utf8");
163
+ const size = Buffer.byteLength(raw, "utf8");
164
+ if (scannedBytes + size > maxBytes)
165
+ break;
166
+ scannedBytes += size;
167
+ const parsed = PendingSchema.safeParse(JSON.parse(raw));
168
+ if (parsed.success && `${parsed.data.pendingId}.json` === name)
169
+ result.push(parsed.data);
136
170
  }
137
171
  catch {
138
- // 失败读取也消耗预算,显式分页命令负责返回诊断和继续扫描。
139
- scannedBytes += Math.min(PENDING_FILE_BYTES, maxBytes - scannedBytes);
172
+ // A damaged pending item is diagnosable and must not hide other candidates.
140
173
  }
141
174
  }
142
175
  return result;
143
176
  }
177
+ function normalize(value) {
178
+ return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
179
+ }
144
180
  function priorityRank(priority) {
145
181
  return priority === "high" ? 0 : priority === "medium" ? 1 : 2;
146
182
  }
183
+ function candidateText(candidate) {
184
+ return [
185
+ candidate.candidate.title,
186
+ candidate.candidate.summary,
187
+ ...candidate.candidate.triggers,
188
+ candidate.candidate.detailCategory,
189
+ candidate.candidate.detailBody,
190
+ ].join("\n");
191
+ }
192
+ function recallScore(candidate, query) {
193
+ const normalizedQuery = normalize(query);
194
+ if (normalizedQuery === "")
195
+ return 1;
196
+ const text = normalize(candidateText(candidate));
197
+ if (text.includes(normalizedQuery))
198
+ return 100;
199
+ const terms = normalizedQuery.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter((term) => term.length > 0);
200
+ return terms.reduce((score, term) => score + (text.includes(term) ? 1 : 0), 0);
201
+ }
147
202
  export async function searchPendingMemoryCandidates(input) {
148
203
  const candidates = await readPendingMemoryCandidates({ agentDir: input.agentDir });
149
204
  const limit = Math.max(0, input.limit ?? 5);
150
205
  if (limit === 0)
151
206
  return [];
152
207
  return candidates
153
- .filter(isRecallablePending)
154
- .map((candidate) => ({ candidate, score: pendingRecallScore(candidate, input.query) }))
155
- .filter((item) => item.score > 0)
208
+ .map((candidate) => ({ candidate, score: recallScore(candidate, input.query) }))
209
+ .filter((item) => normalize(input.query) === "" || item.score > 0)
156
210
  .sort((left, right) => right.score - left.score
157
211
  || priorityRank(left.candidate.candidate.priority) - priorityRank(right.candidate.candidate.priority)
158
212
  || right.candidate.createdAt.localeCompare(left.candidate.createdAt)
@@ -164,29 +218,25 @@ function truncateCodePoints(value, maxCodePoints) {
164
218
  return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
165
219
  }
166
220
  export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
167
- const recallable = candidates.filter(isRecallablePending);
168
- if (recallable.length === 0 || maxCodePoints <= 0)
221
+ if (candidates.length === 0 || maxCodePoints <= 0)
169
222
  return "";
170
- const header = `\n\n## Archived memory candidates (daemon-managed)\n容量暂存的新增资料,仅为部分预览,不代表完整检索;可参考但不能覆盖现行规则,未包含内容请按需查询。\n`;
223
+ const header = `\n\n## Archived memory candidates (daemon-managed)\n这些记忆已持久化,但尚未进入常驻热索引;只在当前请求相关时参考,必要时再由 memory-prune 提升。\n`;
171
224
  let remaining = maxCodePoints - Array.from(header).length;
172
225
  if (remaining <= 0)
173
226
  return truncateCodePoints(header, maxCodePoints);
174
227
  const sections = [];
175
- for (const item of recallable) {
228
+ for (const item of candidates) {
176
229
  const change = item.candidate;
177
230
  const prefix = `\n### ${change.title}\n`
178
- + `- pending_id: ${item.pendingId}\n`
231
+ + `- pending_file: .crew/memory/pending/${item.pendingId}.json\n`
179
232
  + `- summary: ${change.summary}\n`
180
233
  + `- triggers: ${change.triggers.join(", ")}\n`
181
234
  + "- detail: ";
182
235
  const prefixLength = Array.from(prefix).length;
183
236
  if (prefixLength + 1 > remaining)
184
237
  break;
185
- const notice = " [正文未完,请用 read-pending 分页读取]";
186
- const available = remaining - prefixLength - 1;
187
- const truncated = Array.from(change.detailBody).length > available;
188
- const detail = truncateCodePoints(change.detailBody, truncated ? Math.max(0, available - Array.from(notice).length) : available);
189
- const section = `${prefix}${detail}${truncated ? truncateCodePoints(notice, available - Array.from(detail).length) : ""}\n`;
238
+ const detail = truncateCodePoints(change.detailBody, remaining - prefixLength - 1);
239
+ const section = `${prefix}${detail}\n`;
190
240
  const length = Array.from(section).length;
191
241
  sections.push(section);
192
242
  remaining -= length;
@@ -1,7 +1,6 @@
1
1
  import { dslog } from "../slog.js";
2
2
  import { renderPendingMemoryContext, searchPendingMemoryCandidates } from "./pending.js";
3
3
  import { renderLocalMemoryResultInstructions } from "./runtime.js";
4
- const PENDING_READ_GUIDANCE = "\n\nV2新增资料预览未命中不代表没有资料;先查有效索引,必要时用 crew-memory search-pending --query '关键词',按 nextCursor 续查;用 read-pending --id ID 分页读正文。容量暂存资料不能覆盖现行规则,未应用修改不作为记忆。用 crew-memory --help 查看参数。\n";
5
4
  export async function prepareLocalMemoryPrompt(input) {
6
5
  let pending = "";
7
6
  if (input.enabled) {
@@ -22,18 +21,17 @@ export async function prepareLocalMemoryPrompt(input) {
22
21
  }
23
22
  const original = input.renderBase(0);
24
23
  const structured = input.resultPath === null ? "" : renderLocalMemoryResultInstructions(input.resultPath);
25
- const guidance = input.enabled ? PENDING_READ_GUIDANCE : "";
26
- // 短命令指引放在当前轮,独立于可舍弃预览;收尾契约仍保持系统与当前轮两份。
27
- for (const choice of [
28
- { suffix: pending + structured, wake: structured + guidance },
29
- { suffix: structured, wake: structured + guidance },
30
- { suffix: structured, wake: structured },
31
- ]) {
32
- const reserved = Buffer.byteLength(choice.suffix + choice.wake, "utf8");
33
- const systemPrompt = (reserved === 0 ? original : input.renderBase(reserved)) + choice.suffix;
34
- if (input.maxBytes === undefined || Buffer.byteLength(systemPrompt + choice.wake, "utf8") <= input.maxBytes) {
35
- return { systemPrompt, resultPath: input.resultPath, wakePromptSuffix: choice.wake };
36
- }
24
+ const wakeBytes = Buffer.byteLength(structured, "utf8");
25
+ const fits = (prompt) => input.maxBytes === undefined || Buffer.byteLength(prompt, "utf8") + wakeBytes <= input.maxBytes;
26
+ const render = (suffix) => (suffix === "" ? original : input.renderBase(Buffer.byteLength(suffix, "utf8") + wakeBytes)) + suffix;
27
+ // 系统及当前消息各保留一份契约,避免 resume 沿用旧指令;两份均先占预算,再裁可选预览。
28
+ const complete = render(pending + structured);
29
+ if (fits(complete))
30
+ return { systemPrompt: complete, resultPath: input.resultPath, wakePromptSuffix: structured };
31
+ if (pending !== "") {
32
+ const withoutPending = render(structured);
33
+ if (fits(withoutPending))
34
+ return { systemPrompt: withoutPending, resultPath: input.resultPath, wakePromptSuffix: structured };
37
35
  }
38
36
  // 强制基础指令与契约本身仍无法共存时,保留既有非结构化兼容回退,不裁基础指令。
39
37
  return { systemPrompt: original, resultPath: null, wakePromptSuffix: "" };
@@ -5,7 +5,6 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { parseArgs, TextDecoder } from "node:util";
7
7
  import { z } from "zod";
8
- import { readPendingMemoryBody, searchPendingMemoryPage } from "./pending-query.js";
9
8
  const OUTPUT_BYTES = 12 * 1024;
10
9
  const FILE_BYTES = 32 * 1024 * 1024;
11
10
  const FileSchema = z.string().min(1).refine((file) => {
@@ -34,33 +33,14 @@ const CommandSchema = z.discriminatedUnion("command", [
34
33
  offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
35
34
  limit: z.coerce.number().int().min(1).max(4000).default(2000),
36
35
  }).strict(),
37
- z.object({
38
- command: z.literal("search-pending"),
39
- query: z.string().trim().min(1).max(256),
40
- cursor: z.string().min(1).max(4096).optional(),
41
- limit: z.coerce.number().int().min(1).max(40).default(10),
42
- }).strict(),
43
- z.object({
44
- command: z.literal("read-pending"),
45
- id: z.string().min(1).max(250).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
46
- ifHash: Common.ifHash,
47
- offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
48
- limit: z.coerce.number().int().min(1).max(4000).default(2000),
49
- }).strict(),
50
36
  ]);
51
37
  const USAGE = `只读记忆工具,默认输出 JSON,不修改任何记忆文件。
52
38
  crew-memory search --query <关键词> [--file MEMORY.md|notes/文件.md] [--limit 10] [--after-line 0]
53
39
  crew-memory read [--file MEMORY.md|notes/文件.md] [--offset 0] [--limit 2000]
54
- crew-memory search-pending --query <关键词> [--cursor <游标>] [--limit 10]
55
- crew-memory read-pending --id <候选ID> [--offset 0] [--limit 2000] [--if-hash <sha256>]
56
- search 关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
40
+ 搜索关键词按空白分隔,同一行须全部命中;返回完整文件中的行号和字符偏移。
57
41
  读取的 offset/limit 按 Unicode 字符计数;用 nextOffset 继续,eof=true 表示结束。
58
42
  后续页可传 --if-hash <前一页的 sha256>,文件变化时停止,避免混读不同版本。
59
- search/read 读取 MEMORY.md 和 notes 原文,不过滤失效段落,也不扫描 pending。
60
- search-pending 只查因容量延期的 create 候选;有 nextCursor 就继续,即使本页 matches 为空。
61
- complete=true 才表示全部候选已检索完成且无遗漏;warnings 或非零退出码表示读取未完成,不能当作零命中。
62
- read-pending 只返回候选正文;offset 非零时必须传 --if-hash,update/supersede 候选不可读取。
63
- 候选只供相关问题参考,未生效,不能覆盖有效规则;先读取当前有效索引与明细。`;
43
+ 搜索结果是原始资料定位,不代表候选已生效;按当前有效索引读取明细,不扫描 pending。`;
64
44
  function inside(root, path) {
65
45
  const value = relative(root, path);
66
46
  return value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
@@ -172,8 +152,8 @@ function searchSource(text, sha256, command) {
172
152
  nextAfterLine: hasMore ? matches.at(-1)?.line ?? null : null,
173
153
  };
174
154
  }
175
- function readPage(text, source, command) {
176
- const metadata = { ...source, offset: command.offset, nextOffset: Number.MAX_SAFE_INTEGER, eof: false, content: "" };
155
+ function readPage(text, sha256, command) {
156
+ const metadata = { file: command.file, sha256, offset: command.offset, nextOffset: Number.MAX_SAFE_INTEGER, eof: false, content: "" };
177
157
  let remaining = OUTPUT_BYTES - Buffer.byteLength(JSON.stringify(metadata), "utf8");
178
158
  let position = 0;
179
159
  let offset = 0;
@@ -195,16 +175,8 @@ function readPage(text, source, command) {
195
175
  count += 1;
196
176
  }
197
177
  const eof = position === text.length;
198
- if (!eof && count === 0)
199
- throw new Error("memory_output_too_large");
200
178
  return { ...metadata, content, eof, nextOffset: eof ? null : offset };
201
179
  }
202
- function writeResult(result, io) {
203
- const output = JSON.stringify(result);
204
- if (Buffer.byteLength(output, "utf8") > OUTPUT_BYTES)
205
- throw new Error("memory_output_too_large");
206
- io.out(output);
207
- }
208
180
  export async function runMemoryReadCommand(argv, io) {
209
181
  let command;
210
182
  try {
@@ -214,7 +186,6 @@ export async function runMemoryReadCommand(argv, io) {
214
186
  query: { type: "string", short: "q" }, file: { type: "string" },
215
187
  limit: { type: "string" }, offset: { type: "string" },
216
188
  "after-line": { type: "string" }, "if-hash": { type: "string" },
217
- id: { type: "string" }, cursor: { type: "string" },
218
189
  json: { type: "boolean" }, help: { type: "boolean", short: "h" },
219
190
  },
220
191
  });
@@ -230,44 +201,24 @@ export async function runMemoryReadCommand(argv, io) {
230
201
  ...(afterLine === undefined ? {} : { afterLine }),
231
202
  ...(ifHash === undefined ? {} : { ifHash }),
232
203
  });
233
- if (command.command === "read-pending" && command.offset > 0 && command.ifHash === undefined) {
234
- throw new Error("memory_hash_required");
235
- }
236
204
  }
237
205
  catch {
238
- io.err(`memory_arguments_invalid: 参数不合法,记忆读取未完成。\n${USAGE}`);
206
+ io.err(`memory_arguments_invalid: 参数不合法。\n${USAGE}`);
239
207
  return 2;
240
208
  }
241
209
  try {
242
- if (!io.env.CREW_HOME)
243
- throw new Error("memory_home_missing");
244
- if (command.command === "search-pending") {
245
- const result = await searchPendingMemoryPage({ agentDir: io.env.CREW_HOME, query: command.query,
246
- ...(command.cursor === undefined ? {} : { cursor: command.cursor }), limit: command.limit });
247
- writeResult(result, io);
248
- if (result.warnings.length > 0 || (!result.hasMore && !result.complete)) {
249
- throw new Error("memory_pending_search_incomplete");
250
- }
251
- return 0;
252
- }
253
- if (command.command === "read-pending") {
254
- const { content, ...metadata } = await readPendingMemoryBody({ agentDir: io.env.CREW_HOME,
255
- pendingId: command.id, ...(command.ifHash === undefined ? {} : { ifHash: command.ifHash }) });
256
- writeResult(readPage(content, metadata, command), io);
257
- return 0;
258
- }
259
210
  const source = await readSource(io.env.CREW_HOME, command.file);
260
211
  if (command.ifHash !== undefined && command.ifHash !== source.sha256)
261
212
  throw new Error("memory_file_changed");
262
213
  const result = command.command === "search"
263
214
  ? searchSource(source.text, source.sha256, command)
264
- : readPage(source.text, { file: command.file, sha256: source.sha256 }, command);
265
- writeResult(result, io);
215
+ : readPage(source.text, source.sha256, command);
216
+ io.out(JSON.stringify(result));
266
217
  return 0;
267
218
  }
268
219
  catch (error) {
269
220
  const code = error.code;
270
- const known = error instanceof Error && /^(?:local_)?memory_[a-z_]+$/u.test(error.message) ? error.message : code ?? "memory_read_failed";
221
+ const known = error instanceof Error && /^memory_[a-z_]+$/u.test(error.message) ? error.message : code ?? "memory_read_failed";
271
222
  io.err(JSON.stringify({ error: known, message: "记忆读取未完成;检查路径、文件版本或大小,不要将此错误当作没有记忆。" }));
272
223
  return 1;
273
224
  }
@@ -69,8 +69,8 @@ outcome 只允许 no_change、proposed、conflict、failed。新增、更新、
69
69
  \`\`\`
70
70
 
71
71
  每个 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。
72
- 只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中。容量暂存的 create 可按需检索参考但不能覆盖现行规则;有空间的后续 proposed 提交可提升。未应用的 update/supersede 不进入普通召回,必须重新核对目标版本后处理。
73
- 写入候选文件只表示提交候选,不能提前宣称已经记住或规则已经更新;daemon 在本轮结束后才处理,候选持久化与进入有效索引也不是同一结果。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在处理成功后清理,失败时保留日志和候选用于排查或重试。
72
+ 只提交可复用、与本次任务明确相关的少量候选;不要写入凭证、token、隐私或内部控制标记。daemon 会校验、去重、限制 MEMORY.md 大小,超出索引容量的候选会保存在 pending 归档中;后续相关任务会按需召回,索引腾出空间后可自动提升。
73
+ 写入候选文件不等于已经记住,只有 daemon 校验并提交成功后记忆才生效。本结构化轮次覆盖前文提前清理工作日志的要求:保留 $CREW_TASK_LOG,不要清空或删除;daemon 将在提交成功后清理,失败时保留日志和候选用于排查或重试。
74
74
  `;
75
75
  }
76
76
  /** Prevent ordinary V2 turns from racing the daemon-managed memory writer. */