@nowcrew/daemon 0.6.87 → 0.6.88-beta.10

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.
Files changed (36) hide show
  1. package/README.md +45 -10
  2. package/dist/agent-memory/policy.js +3 -1
  3. package/dist/cloud-report-receipt.js +83 -0
  4. package/dist/cloud-report-uploader.js +36 -25
  5. package/dist/execution-protocol.js +1 -1
  6. package/dist/execution-runner.js +9 -5
  7. package/dist/list-models.js +2 -0
  8. package/dist/local-execution-prompt.js +19 -25
  9. package/dist/local-executor.js +67 -85
  10. package/dist/local-memory/candidate.js +43 -0
  11. package/dist/local-memory/pending-match.js +29 -0
  12. package/dist/local-memory/pending-query.js +100 -0
  13. package/dist/local-memory/pending-schema.js +29 -0
  14. package/dist/local-memory/pending-source.js +126 -0
  15. package/dist/local-memory/pending.js +34 -84
  16. package/dist/local-memory/prompt.js +40 -0
  17. package/dist/local-memory/read-cli.js +281 -0
  18. package/dist/local-memory/runtime.js +56 -7
  19. package/dist/local-memory/write.js +16 -48
  20. package/dist/local-memory-diagnostics.js +79 -12
  21. package/dist/native-thread-context.js +59 -0
  22. package/dist/prompt.js +39 -24
  23. package/dist/runner.js +14 -15
  24. package/dist/runtime-environment-telemetry.js +27 -0
  25. package/dist/runtimes/claude-session-start-hook.js +24 -0
  26. package/dist/runtimes/claude-thread-runner.js +266 -0
  27. package/dist/runtimes/claude.js +23 -4
  28. package/dist/runtimes/codex-app-server-runner.js +43 -4
  29. package/dist/runtimes/kimi-acp-runner.js +6 -2
  30. package/dist/runtimes/opencode-runner.js +7 -1
  31. package/dist/serve.js +7 -8
  32. package/dist/session.js +4 -0
  33. package/dist/supervised-runtime.js +11 -16
  34. package/dist/thread-input-delivery.js +217 -0
  35. package/dist/workspace.js +15 -0
  36. package/package.json +2 -2
@@ -12,8 +12,11 @@ import { spawnKimi, KIMI_EFFORT_LEVELS } from "./runtimes/kimi.js";
12
12
  import { applyDeepSeekHarnessMachineEnv, applyProviderEnv, providerFingerprint, } from "./provider-env.js";
13
13
  import { augmentedPath } from "./runtime-path.js";
14
14
  import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report-uploader.js";
15
+ import { reportDeliveryContext } from "./cloud-report-receipt.js";
15
16
  import { extractFinalText, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
16
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";
17
20
  import { providerErrorFromExit } from "./provider-error.js";
18
21
  import { createConsoleFormatter } from "./console-formatter.js";
19
22
  import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
@@ -34,10 +37,11 @@ import { diffMemoryPruneNotes, evaluateMemoryPrunePostcondition, inspectMemoryPr
34
37
  import { createLocalMemoryTelemetry, logLocalMemoryContextPrepareFailure, logLocalMemoryDiagnosticsFailure, } from "./local-memory-telemetry.js";
35
38
  import { CodexStartupStageParser } from "./codex-startup-stage.js";
36
39
  import { formatRuntimeStartupError } from "./runtime-startup-error.js";
37
- import { machinePackageManagerEnv, packageManagerEnvFacts } from "./package-manager-env.js";
40
+ import { machinePackageManagerEnv } from "./package-manager-env.js";
41
+ import { logRuntimeEnvironmentContract } from "./runtime-environment-telemetry.js";
38
42
  import { sanitizeEnvVars } from "./runtime-env.js";
39
- import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, renderLocalMemoryResultInstructions, } from "./local-memory/runtime.js";
40
- import { renderPendingMemoryContext, searchPendingMemoryCandidates, } from "./local-memory/pending.js";
43
+ import { commitLocalMemoryResultAfterExit, initializeLocalMemoryV2ForWorkspace, isLocalMemoryV2Eligible, localMemoryResultPath as makeLocalMemoryResultPath, } from "./local-memory/runtime.js";
44
+ import { prepareLocalMemoryPrompt } from "./local-memory/prompt.js";
41
45
  export class RuntimeRunningCallbackTimeoutError extends Error {
42
46
  constructor(runtime, timeoutMs) {
43
47
  super(`${runtime} runtime-running callback timed out after ${timeoutMs}ms`);
@@ -106,6 +110,9 @@ async function launchLegacyRuntime(request) {
106
110
  if (request.runtime === "deepseek-harness") {
107
111
  throw new Error("DeepSeek Harness requires execution protocol v1");
108
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 };
109
116
  const common = {
110
117
  bin: request.bin,
111
118
  cwd: request.cwd,
@@ -173,6 +180,7 @@ async function withKeyedLease(key, operation, cancellation) {
173
180
  }
174
181
  }
175
182
  export async function executeLocal(input, callbacks = {}, dependencies = {}) {
183
+ input = isolateMaintenanceSession(input);
176
184
  const versionedProjectSkills = input.projectSkills !== undefined
177
185
  && !Array.isArray(input.projectSkills);
178
186
  if (versionedProjectSkills && (input.runtime.name !== "codex" && input.runtime.name !== "claude")) {
@@ -253,9 +261,15 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
253
261
  let localMemoryResultPath = null;
254
262
  let executionWorkspace = workspace;
255
263
  let localMemoryTelemetry = null;
264
+ let nativeThreadContext = null;
256
265
  const inheritedEnv = { ...process.env };
257
- const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, root: workspace.dir, executionId: input.executionId, agentHandle: input.handle });
266
+ const reportRoots = {
267
+ root: workspace.dir,
268
+ ...(input.launch.qaReportRoot === undefined ? {} : { qaReportRoot: input.launch.qaReportRoot }),
269
+ };
270
+ const cloudReportPreparation = await prepareCloudReportExecution({ env: inheritedEnv, ...reportRoots, executionId: input.executionId, agentHandle: input.handle });
258
271
  const { facts: cloudReportUpload, snapshot: reportSnapshot } = cloudReportPreparation;
272
+ const reportContext = reportDeliveryContext(input.executionId, workspace.runDir, cloudReportUpload);
259
273
  for (const key of Object.keys(inheritedEnv)) {
260
274
  if (key.startsWith("CREW_AGENT_MEMORY_") || ["CREW_CLOUD_PROVIDER", "CREW_OSS_UPLOAD_SCRIPT", "CREW_OSS_REPORT_UPLOAD"].includes(key))
261
275
  delete inheritedEnv[key];
@@ -287,12 +301,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
287
301
  : null;
288
302
  const currentPrior = storedCurrentPrior !== null
289
303
  && storedCurrentPrior.sessionContextFingerprint === input.sessionContextFingerprint
304
+ && storedCurrentPrior.runtime === runtime.name
305
+ && storedCurrentPrior.threadRoot === input.resumeKey
290
306
  ? storedCurrentPrior
291
307
  : null;
292
- // Interactive V1 uses collision-resistant session directories, while the
293
- // older legacy path stored metadata under tasks/<safe thread key>. Import
294
- // that metadata once so an existing thread can cross the protocol boundary
295
- // without losing its native runtime conversation.
308
+ // Import compatible legacy metadata across the protocol boundary.
296
309
  const legacyRunDir = input.session.enabled
297
310
  && supportsNativeResume
298
311
  && input.keyMode === "opaque"
@@ -308,7 +321,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
308
321
  const legacyPrior = legacyRunDir === null || (currentPrior !== null && !legacyCwdPinned)
309
322
  ? null
310
323
  : await readSession(legacyRunDir);
311
- const prior = currentPrior ?? legacyPrior;
324
+ const compatibleLegacyPrior = legacyPrior?.runtime === runtime.name && legacyPrior?.threadRoot === input.resumeKey
325
+ ? legacyPrior : null;
326
+ const prior = currentPrior ?? compatibleLegacyPrior;
312
327
  const sessionExists = workspace.sessionResume || legacyPrior !== null;
313
328
  const usesLegacyCwd = legacyRunDir !== null
314
329
  && (legacyCwdPinned || (currentPrior === null && legacyPrior !== null));
@@ -329,11 +344,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
329
344
  ? prior?.model ?? currentModel
330
345
  : currentModel;
331
346
  const resumeSessionId = supportsNativeResume && sessionExists
332
- ? 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)
333
348
  : null;
334
349
  const resuming = resumeSessionId !== null;
335
350
  const rotatedForBudget = !resuming && supportsNativeResume && sessionExists
336
- && 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;
337
352
  const nearBudget = resuming && isNearBudget(prior, input.session.softTokens);
338
353
  const rotated = Boolean(workspace.agentSessionId && sessionExists && !resuming);
339
354
  // Codex and Kimi choose their own id on a fresh start, so the native id persisted in
@@ -381,28 +396,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
381
396
  }
382
397
  }
383
398
  const attachmentPlan = routeRuntimeAttachments(runtime.name, materialized?.attachments ?? []);
384
- const wakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
385
- let pendingMemoryContext = "";
386
- if (input.localMemoryV2Enabled === true) {
387
- try {
388
- const pendingCandidates = await searchPendingMemoryCandidates({
389
- agentDir: executionWorkspace.dir,
390
- query: wakePrompt,
391
- limit: 5,
392
- });
393
- pendingMemoryContext = renderPendingMemoryContext(pendingCandidates);
394
- }
395
- catch (error) {
396
- dslog("local_memory.pending_recall_failed", "本地归档记忆召回失败", {
397
- level: "WARN",
398
- execution_id: input.executionId,
399
- agent_handle: input.handle,
400
- error_name: error instanceof Error ? error.name : "unknown",
401
- error_code: error?.code,
402
- });
403
- }
404
- }
405
- memoryPruneTraceId = parseMemoryPruneTraceId(wakePrompt);
399
+ const baseWakePrompt = `${resolvePrompt(input.wakePrompt, promptContext)}${attachmentPlan.promptSuffix}`;
400
+ memoryPruneTraceId = parseMemoryPruneTraceId(baseWakePrompt);
406
401
  if (input.localMemoryV2Enabled === true && memoryPruneTraceId !== null
407
402
  && isLocalMemoryV2Eligible({
408
403
  enabledForHandle: true,
@@ -413,26 +408,15 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
413
408
  })) {
414
409
  localMemoryResultPath = makeLocalMemoryResultPath(executionWorkspace.runDir, input.executionId);
415
410
  }
416
- const promptWithoutPending = resolvePrompt(input.systemPrompt, promptContext);
417
- let baseSystemPrompt = `${promptWithoutPending}${pendingMemoryContext}`;
418
- const structuredMemoryInstructions = localMemoryResultPath === null
419
- ? null
420
- : renderLocalMemoryResultInstructions(localMemoryResultPath);
421
- const fitsPromptBudget = (prompt) => structuredMemoryInstructions === null
422
- || input.maxSystemPromptBytes === undefined
423
- || Buffer.byteLength(prompt, "utf8") + Buffer.byteLength(structuredMemoryInstructions, "utf8")
424
- <= input.maxSystemPromptBytes;
425
- // The structured prune contract is mandatory for V2. Drop optional archived
426
- // recall before dropping the daemon result file when the prompt budget is tight.
427
- if (!fitsPromptBudget(baseSystemPrompt) && pendingMemoryContext !== "") {
428
- baseSystemPrompt = promptWithoutPending;
429
- }
430
- const structuredPromptFits = fitsPromptBudget(baseSystemPrompt);
431
- if (!structuredPromptFits)
432
- localMemoryResultPath = null;
433
- const systemPrompt = localMemoryResultPath === null
434
- ? baseSystemPrompt
435
- : `${baseSystemPrompt}${structuredMemoryInstructions ?? ""}`;
411
+ const memoryPrompt = await prepareLocalMemoryPrompt({
412
+ agentDir: executionWorkspace.dir, executionId: input.executionId, agentHandle: input.handle,
413
+ enabled: input.localMemoryV2Enabled === true, query: baseWakePrompt,
414
+ resultPath: localMemoryResultPath, maxBytes: input.maxSystemPromptBytes,
415
+ renderBase: (reservedPromptBytes) => resolvePrompt(input.systemPrompt, { ...promptContext, reservedPromptBytes }),
416
+ });
417
+ localMemoryResultPath = memoryPrompt.resultPath;
418
+ const systemPrompt = memoryPrompt.systemPrompt;
419
+ const wakePrompt = baseWakePrompt + memoryPrompt.wakePromptSuffix;
436
420
  if (!isolatedMaintenance)
437
421
  try {
438
422
  localMemoryTelemetry = createLocalMemoryTelemetry({
@@ -491,6 +475,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
491
475
  }
492
476
  memoryPruneFailurePhase = "runtime_prepare";
493
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
+ }
494
483
  const baseEnv = {
495
484
  ...inheritedEnv,
496
485
  ...sanitizeEnvVars(providerConfig.envVars),
@@ -503,6 +492,9 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
503
492
  CREW_TOKEN: input.launch.token,
504
493
  CREW_CHANNEL: input.channelId,
505
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 ?? "",
506
498
  // Keep the daemon-selected storage root authoritative for runtimes.
507
499
  // In profile-backed NowWork sessions the daemon config may differ from
508
500
  // the daemon process environment, so inheriting CREW_AGENTS_ROOT is not
@@ -513,6 +505,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
513
505
  // Expose only the training root; ability-specific workspace layout stays
514
506
  // owned by the training package rather than the NowWork daemon.
515
507
  QA_RUNTIME_ROOT: join(crewHome, "training"),
508
+ QA_REPORT_DELIVERY_CONTEXT: JSON.stringify(reportContext),
516
509
  CREW_TASK_DIR: executionWorkspace.runDir,
517
510
  CREW_TASK_LOG: executionWorkspace.workLogPath,
518
511
  ...(input.wakeMessageId ? { CREW_WAKE_MESSAGE_ID: input.wakeMessageId } : {}),
@@ -532,30 +525,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
532
525
  const childEnv = runtime.name === "deepseek-harness"
533
526
  ? applyDeepSeekHarnessMachineEnv(providerEnv, inheritedEnv)
534
527
  : providerEnv;
535
- dslog("runtime.environment_contract", "Runtime 子进程环境契约", {
536
- execution_id: input.executionId,
537
- agent_handle: input.handle,
538
- runtime: runtime.name,
539
- qa_runtime_root: childEnv.QA_RUNTIME_ROOT ?? null,
540
- qa_runtime_root_present: childEnv.QA_RUNTIME_ROOT !== undefined,
541
- inherited_qa_runtime_root_present: inheritedEnv.QA_RUNTIME_ROOT !== undefined,
542
- ...packageManagerEnvFacts(childEnv),
543
- training_root: join(crewHome, "training"),
544
- cloud_provider: cloudReportUpload.provider,
545
- oss_report_upload_enabled: cloudReportUpload.enabled,
546
- project_context_present: input.projectContext !== undefined,
547
- project_ids: JSON.stringify(input.projectContext === undefined
548
- ? []
549
- : [
550
- ...(input.projectContext.primary === undefined
551
- ? []
552
- : [input.projectContext.primary.projectId]),
553
- ...input.projectContext.secondary.map((project) => project.projectId),
554
- ]),
555
- ability_release_present: input.abilityRelease !== undefined,
556
- ability_release_id: input.abilityRelease?.releaseId,
557
- ability_root_commit: input.abilityRelease?.rootCommit,
558
- });
528
+ logRuntimeEnvironmentContract(input, childEnv, inheritedEnv, crewHome, cloudReportUpload);
559
529
  const warn = dependencies.warnRuntime ?? warnRuntimeLocally;
560
530
  const launchRuntime = dependencies.launchRuntime ?? (async (launchReq) => {
561
531
  if (launchReq.runtime === "codex" && launchReq.codexCollaborationTools === true) {
@@ -572,7 +542,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
572
542
  const effectiveSystemPrompt = abilityContext === undefined
573
543
  ? systemPrompt
574
544
  : `${systemPrompt}\n\n${abilityContext.prompt}`;
575
- if (Buffer.byteLength(effectiveSystemPrompt, "utf8") > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
545
+ if (Buffer.byteLength(effectiveSystemPrompt, "utf8") + Buffer.byteLength(memoryPrompt.wakePromptSuffix, "utf8")
546
+ > (input.maxSystemPromptBytes ?? Number.MAX_SAFE_INTEGER)) {
576
547
  throw new Error("ability_runtime_context_size_exceeded");
577
548
  }
578
549
  memoryPruneFailurePhase = "prompt_write";
@@ -848,6 +819,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
848
819
  const event = parseLine(line);
849
820
  if (!event)
850
821
  return;
822
+ nativeThreadContext?.observe(event);
851
823
  localMemoryTelemetry?.observe(event);
852
824
  if (isRuntimeReadyEvent(runtime.name, event))
853
825
  markRuntimeReady();
@@ -964,22 +936,27 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
964
936
  localMemoryTelemetry?.markRuntimeExited(exitCode);
965
937
  memoryPruneRuntimeExitCode = exitCode;
966
938
  if (localMemoryResultPath !== null) {
939
+ memoryPruneFailurePhase = "memory_commit";
967
940
  const committed = await commitLocalMemoryResultAfterExit({
968
941
  resultPath: localMemoryResultPath,
969
942
  agentDir: executionWorkspace.dir,
970
943
  executionId: input.executionId,
971
944
  pruneTraceId: memoryPruneTraceId,
972
945
  exitCode,
946
+ workLogPath: executionWorkspace.workLogPath,
973
947
  });
974
- if (committed.status === "failed") {
975
- dslog("local_memory.commit_failed", "结构化本地记忆候选未落地,保留结果文件", {
976
- level: "WARN",
948
+ if (committed.status === "failed" || committed.status === "missing") {
949
+ const reason = committed.error ?? "local_memory_result_missing";
950
+ dslog("local_memory.commit_failed", "结构化本地记忆提交或清理未完成,保留可用恢复材料", {
951
+ level: "ERROR",
977
952
  execution_id: input.executionId,
978
953
  agent_handle: input.handle,
979
- error_code: committed.error,
954
+ error_code: reason,
980
955
  result_path: localMemoryResultPath,
981
956
  });
957
+ throw new Error(`local_memory_commit_failed: ${reason}`);
982
958
  }
959
+ memoryPruneFailurePhase = "runtime_finalize";
983
960
  }
984
961
  const errorTail = [
985
962
  stderrTail.trim(),
@@ -1001,9 +978,12 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1001
978
  ? usage.inputTokens + usage.cacheReadTokens + usage.cacheCreationTokens
1002
979
  : (resuming ? prior?.contextTokens : undefined);
1003
980
  await writeSession(workspace.sessionDir, {
981
+ runtime: runtime.name,
982
+ ...(input.resumeKey === undefined ? {} : { threadRoot: input.resumeKey }),
983
+ ...(exitCode === 0 ? await nativeThreadContext?.checkpoint() ?? {} : {}),
1004
984
  sessionId,
1005
985
  lastRunAt: Date.now(),
1006
- turns: resuming ? (prior?.turns ?? 0) + 1 : 1,
986
+ turns: nativeThreadContext?.confirmed ? (prior?.turns ?? 0) + 1 : 1,
1007
987
  model: currentModel,
1008
988
  providerFingerprint: providerFp,
1009
989
  ...(input.sessionContextFingerprint === undefined
@@ -1018,7 +998,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1018
998
  }
1019
999
  const cloudReportResult = exitCode === 0
1020
1000
  ? await deliverCloudReports({
1021
- facts: cloudReportUpload, root: workspace.dir, before: reportSnapshot, finalText,
1001
+ facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText,
1002
+ receiptFile: reportContext.receiptFile,
1022
1003
  externalAnswer: externalOutput.lastClosedBlock
1023
1004
  ?? (finalText === null ? null : extractExternalAnswer(finalText)),
1024
1005
  captureFinal: input.captureFinal, executionId: input.executionId, agentHandle: input.handle,
@@ -1036,7 +1017,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1036
1017
  ...(usage === undefined ? {} : { usage }),
1037
1018
  model: observedModel,
1038
1019
  runtime: runtime.name,
1039
- resumed: resuming,
1020
+ resumed: nativeThreadContext?.confirmed ?? false,
1040
1021
  sessionId,
1041
1022
  errorMessage: errorTail || null,
1042
1023
  ...(providerError === undefined ? {} : { providerError }),
@@ -1048,6 +1029,7 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
1048
1029
  };
1049
1030
  }
1050
1031
  finally {
1032
+ await nativeThreadContext?.cleanup().catch(() => undefined);
1051
1033
  startupReservationState.current?.release();
1052
1034
  try {
1053
1035
  await localMemoryTelemetry?.finish();
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { containsLikelySecret } from "../agent-memory/policy.js";
3
+ const CategorySchema = z.enum([
4
+ "preferences", "project-rules", "testing", "debugging", "git", "incidents", "lessons",
5
+ ]);
6
+ export const LocalMemorySingleLineText = z.string().trim().min(1).max(500).regex(/^[^\r\n]+$/u);
7
+ export const LocalMemoryChangeSchema = z.object({
8
+ operation: z.enum(["create", "update", "supersede"]),
9
+ memoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
10
+ baseRevision: z.number().int().nonnegative().nullable(),
11
+ supersedesMemoryId: LocalMemorySingleLineText.regex(/^[^<>]+$/u).nullable(),
12
+ title: LocalMemorySingleLineText.max(120),
13
+ kind: z.enum(["procedural", "semantic", "episodic", "preference"]),
14
+ summary: LocalMemorySingleLineText,
15
+ triggers: z.array(LocalMemorySingleLineText.max(80)).min(1).max(16),
16
+ priority: z.enum(["high", "medium", "low"]),
17
+ detailCategory: CategorySchema,
18
+ detailBody: z.string().trim().min(1).max(12_000),
19
+ reviewAfter: LocalMemorySingleLineText.nullable(),
20
+ }).strict();
21
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
22
+ const RESERVED_MEMORY_MARKER = /NOWWORK:(?:MEMORY_ENTRY|MEMORY_INDEX):/iu;
23
+ export function validateLocalMemoryChange(change) {
24
+ const text = [change.title, change.summary, ...change.triggers, change.detailBody].join("\n");
25
+ if (containsLikelySecret(text))
26
+ throw new Error("local_memory_secret_candidate");
27
+ if (CONTROL_CHARACTER_PATTERN.test(text))
28
+ throw new Error("local_memory_control_character");
29
+ if (RESERVED_MEMORY_MARKER.test(text))
30
+ throw new Error("local_memory_reserved_marker");
31
+ if (change.operation === "create"
32
+ && (change.memoryId !== null || change.baseRevision !== null || change.supersedesMemoryId !== null)) {
33
+ throw new Error("local_memory_create_revision_invalid");
34
+ }
35
+ if (change.operation === "update"
36
+ && (change.memoryId === null || change.baseRevision === null || change.supersedesMemoryId !== null)) {
37
+ throw new Error("local_memory_update_revision_missing");
38
+ }
39
+ if (change.operation === "supersede"
40
+ && (change.memoryId !== null || change.baseRevision === null || change.supersedesMemoryId === null)) {
41
+ throw new Error("local_memory_supersede_revision_missing");
42
+ }
43
+ }
@@ -0,0 +1,29 @@
1
+ export function normalizePendingQuery(value) {
2
+ return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/gu, " ").trim();
3
+ }
4
+ function containsTerm(text, term) {
5
+ if (!term)
6
+ return false;
7
+ const startBoundary = /^[a-z0-9]/u.test(term);
8
+ const endBoundary = /[a-z0-9]$/u.test(term);
9
+ let position = text.indexOf(term);
10
+ while (position >= 0) {
11
+ const before = text[position - 1] ?? "";
12
+ const after = text[position + term.length] ?? "";
13
+ if ((!startBoundary || !/[a-z0-9_-]/u.test(before)) && (!endBoundary || !/[a-z0-9_-]/u.test(after)))
14
+ return true;
15
+ position = text.indexOf(term, position + 1);
16
+ }
17
+ return false;
18
+ }
19
+ export function pendingRecallScore(item, query) {
20
+ const normalized = normalizePendingQuery(query);
21
+ if (!normalized)
22
+ return 0;
23
+ const candidate = item.candidate;
24
+ const terms = normalized.split(/[\s,,、。!?;::()[\]{}"'`]+/u).filter(Boolean);
25
+ const text = normalizePendingQuery([candidate.title, candidate.summary, ...candidate.triggers, candidate.detailCategory, candidate.detailBody].join("\n"));
26
+ const explicit = [candidate.title, ...candidate.triggers].map(normalizePendingQuery);
27
+ const explicitScore = explicit.reduce((score, term) => score + (containsTerm(normalized, term) ? 10 : 0), 0);
28
+ return explicitScore + terms.reduce((score, term) => score + (containsTerm(text, term) ? 1 : 0), 0);
29
+ }
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { isRecallablePending, PendingIdSchema } from "./pending-schema.js";
4
+ import { normalizePendingQuery, pendingRecallScore } from "./pending-match.js";
5
+ import { pendingDirectorySnapshot, pendingSourceError, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
6
+ const PAGE_FILES = 128;
7
+ const PAGE_BYTES = 1_000_000;
8
+ const OUTPUT_BYTES = 12 * 1024;
9
+ const CursorSchema = z.object({
10
+ v: z.literal(1), queryHash: z.string().regex(/^[a-f0-9]{64}$/u), version: z.string().regex(/^[a-f0-9]{64}$/u),
11
+ after: z.string().min(1).max(255), hadWarnings: z.boolean(),
12
+ }).strict();
13
+ const SearchSchema = z.object({
14
+ agentDir: z.string().min(1), query: z.string().trim().min(1).max(256),
15
+ cursor: z.string().max(2048).optional(), limit: z.number().int().min(1).max(40).default(10),
16
+ }).strict();
17
+ const excerpt = (text, cap) => Array.from(text).slice(0, cap).join("");
18
+ export async function searchPendingMemoryPage(input) {
19
+ const parsed = SearchSchema.safeParse(input);
20
+ if (!parsed.success)
21
+ throw new Error("memory_pending_arguments_invalid");
22
+ const args = parsed.data;
23
+ const queryHash = createHash("sha256").update(normalizePendingQuery(args.query)).digest("hex");
24
+ let cursor;
25
+ if (args.cursor !== undefined) {
26
+ try {
27
+ cursor = CursorSchema.parse(JSON.parse(Buffer.from(args.cursor, "base64url").toString("utf8")));
28
+ }
29
+ catch {
30
+ throw new Error("memory_pending_cursor_invalid");
31
+ }
32
+ if (cursor.queryHash !== queryHash)
33
+ throw new Error("memory_pending_cursor_invalid");
34
+ }
35
+ const snapshot = await pendingDirectorySnapshot(args.agentDir);
36
+ if (cursor && cursor.version !== snapshot.version)
37
+ throw new Error("memory_pending_source_changed");
38
+ const initial = cursor ? snapshot.names.indexOf(cursor.after) + 1 : 0;
39
+ if (cursor && initial === 0)
40
+ throw new Error("memory_pending_cursor_invalid");
41
+ const matches = [];
42
+ const warnings = [];
43
+ let position = initial;
44
+ let scannedFiles = 0;
45
+ let scannedBytes = 0;
46
+ // 为游标、错误与元数据预留空间;达到输出上限前停止消费来源,不跳过未输出的命中。
47
+ let outputBytes = 1600;
48
+ while (position < snapshot.names.length && scannedFiles < PAGE_FILES && scannedBytes + PENDING_FILE_BYTES <= PAGE_BYTES) {
49
+ const name = snapshot.names[position];
50
+ let source;
51
+ let warning;
52
+ try {
53
+ source = await readPendingSource(args.agentDir, name);
54
+ }
55
+ catch (error) {
56
+ const code = pendingSourceError(error);
57
+ if (code === "memory_pending_source_changed" || code === "memory_pending_not_found")
58
+ throw new Error("memory_pending_source_changed");
59
+ warning = { file: name, code };
60
+ }
61
+ const match = source && isRecallablePending(source.candidate) && pendingRecallScore(source.candidate, args.query) > 0
62
+ ? { pendingId: source.candidate.pendingId, sha256: source.sha256, title: source.candidate.candidate.title,
63
+ summary: excerpt(source.candidate.candidate.summary, 180), triggers: source.candidate.candidate.triggers.slice(0, 4),
64
+ operation: "create", state: "capacity_deferred" }
65
+ : null;
66
+ const addition = match ?? warning;
67
+ const size = addition ? Buffer.byteLength(JSON.stringify(addition), "utf8") + 2 : 0;
68
+ if (outputBytes + size > OUTPUT_BYTES)
69
+ break;
70
+ if (match)
71
+ matches.push(match);
72
+ if (warning)
73
+ warnings.push(warning);
74
+ outputBytes += size;
75
+ scannedFiles += 1;
76
+ scannedBytes += source?.bytes ?? PENDING_FILE_BYTES;
77
+ position += 1;
78
+ if (matches.length >= args.limit)
79
+ break;
80
+ }
81
+ if ((await pendingDirectorySnapshot(args.agentDir)).version !== snapshot.version)
82
+ throw new Error("memory_pending_source_changed");
83
+ const hasMore = position < snapshot.names.length;
84
+ const hadWarnings = cursor?.hadWarnings === true || warnings.length > 0;
85
+ const nextCursor = hasMore ? Buffer.from(JSON.stringify({ v: 1, queryHash, version: snapshot.version,
86
+ after: snapshot.names[position - 1], hadWarnings })).toString("base64url") : null;
87
+ return { matches, warnings, scannedFiles, scannedBytes, hasMore, nextCursor, complete: !hasMore && !hadWarnings };
88
+ }
89
+ export async function readPendingMemoryBody(input) {
90
+ if (!PendingIdSchema.safeParse(input.pendingId).success)
91
+ throw new Error("memory_pending_id_invalid");
92
+ const source = await readPendingSource(input.agentDir, `${input.pendingId}.json`);
93
+ if (input.ifHash !== undefined && input.ifHash !== source.sha256)
94
+ throw new Error("memory_pending_source_changed");
95
+ if (!isRecallablePending(source.candidate))
96
+ throw new Error("memory_pending_not_recallable");
97
+ const candidate = source.candidate.candidate;
98
+ return { pendingId: source.candidate.pendingId, sha256: source.sha256, title: candidate.title,
99
+ operation: "create", state: "capacity_deferred", content: candidate.detailBody };
100
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import { LocalMemoryChangeSchema, validateLocalMemoryChange } from "./candidate.js";
3
+ // 索引会按分隔符展开触发词;16 个最多 80 UTF-16 单元的输入可产生 0..640 项。
4
+ const ArchivedMemoryChangeSchema = LocalMemoryChangeSchema.extend({
5
+ triggers: z.array(LocalMemoryChangeSchema.shape.triggers.element).max(640)
6
+ .refine((triggers) => triggers.reduce((length, trigger) => length + trigger.length, 0) <= 16 * 80),
7
+ });
8
+ export const PendingIdSchema = z.string().max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
9
+ export const PendingMemorySchema = z.object({
10
+ schemaVersion: z.literal(1), pendingId: PendingIdSchema,
11
+ createdAt: z.string().min(1).max(100), reason: z.literal("index_over_budget"),
12
+ source: z.object({ executionId: z.string().min(1).max(500), pruneTraceId: z.string().min(1).max(500).nullable() }).strict(),
13
+ candidate: ArchivedMemoryChangeSchema,
14
+ }).strict();
15
+ export function parsePendingMemoryCandidate(value) {
16
+ const parsed = PendingMemorySchema.safeParse(value);
17
+ if (!parsed.success)
18
+ throw new Error("local_memory_pending_invalid");
19
+ validateLocalMemoryChange(parsed.data.candidate);
20
+ return parsed.data;
21
+ }
22
+ export function isRecallablePending(value) {
23
+ try {
24
+ return parsePendingMemoryCandidate(value).candidate.operation === "create";
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,126 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, opendir, realpath } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { TextDecoder } from "node:util";
6
+ import { parsePendingMemoryCandidate, PendingIdSchema } from "./pending-schema.js";
7
+ export const PENDING_FILE_BYTES = 128 * 1024;
8
+ const DIRECTORY_ENTRY_CAP = 10_000;
9
+ export function pendingSourceError(error) {
10
+ if (error instanceof Error && /^memory_pending_[a-z_]+$/u.test(error.message))
11
+ return error.message;
12
+ return "memory_pending_file_invalid";
13
+ }
14
+ export async function pendingReadDirectory(agentDir) {
15
+ const root = await realpath(agentDir);
16
+ let directory = root;
17
+ for (const part of [".crew", "memory", "pending"]) {
18
+ directory = join(directory, part);
19
+ let stat;
20
+ try {
21
+ stat = await lstat(directory);
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT")
25
+ return { root, directory: null };
26
+ throw new Error("memory_pending_directory_unreadable");
27
+ }
28
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== directory) {
29
+ throw new Error("memory_pending_path_invalid");
30
+ }
31
+ }
32
+ return { root, directory };
33
+ }
34
+ export async function pendingFileNames(directory) {
35
+ if (directory === null)
36
+ return [];
37
+ const names = [];
38
+ let inspected = 0;
39
+ try {
40
+ for await (const item of await opendir(directory)) {
41
+ if (++inspected > DIRECTORY_ENTRY_CAP)
42
+ throw new Error("memory_pending_directory_over_budget");
43
+ if (item.name.endsWith(".json"))
44
+ names.push(item.name);
45
+ }
46
+ }
47
+ catch (error) {
48
+ if (error instanceof Error && error.message.startsWith("memory_pending_"))
49
+ throw error;
50
+ throw new Error("memory_pending_directory_unreadable");
51
+ }
52
+ return names.sort();
53
+ }
54
+ function version(stat) {
55
+ return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
56
+ }
57
+ export async function pendingDirectorySnapshot(agentDir) {
58
+ const location = await pendingReadDirectory(agentDir);
59
+ const names = await pendingFileNames(location.directory);
60
+ const digest = createHash("sha256").update(location.root);
61
+ if (location.directory !== null)
62
+ digest.update(version(await lstat(location.directory)));
63
+ for (const name of names) {
64
+ try {
65
+ digest.update(JSON.stringify([name, version(await lstat(join(location.directory, name)))]));
66
+ }
67
+ catch {
68
+ throw new Error("memory_pending_source_changed");
69
+ }
70
+ }
71
+ return { ...location, names, version: digest.digest("hex") };
72
+ }
73
+ export async function readPendingSource(agentDir, name, byteBudget = PENDING_FILE_BYTES) {
74
+ if (!name.endsWith(".json") || !PendingIdSchema.safeParse(name.slice(0, -5)).success) {
75
+ throw new Error("memory_pending_id_invalid");
76
+ }
77
+ const { directory } = await pendingReadDirectory(agentDir);
78
+ if (directory === null)
79
+ throw new Error("memory_pending_not_found");
80
+ const path = join(directory, name);
81
+ const before = await lstat(path).catch(() => { throw new Error("memory_pending_not_found"); });
82
+ if (!before.isFile() || before.isSymbolicLink())
83
+ throw new Error("memory_pending_path_invalid");
84
+ const limit = Math.min(byteBudget, PENDING_FILE_BYTES);
85
+ if (before.size > limit)
86
+ throw new Error("memory_pending_file_over_budget");
87
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
88
+ try {
89
+ const opened = await handle.stat();
90
+ if (!opened.isFile() || version(opened) !== version(before))
91
+ throw new Error("memory_pending_source_changed");
92
+ const chunks = [];
93
+ let bytes = 0;
94
+ for (;;) {
95
+ const chunk = Buffer.alloc(Math.min(16 * 1024, limit + 1 - bytes));
96
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
97
+ if (bytesRead === 0)
98
+ break;
99
+ bytes += bytesRead;
100
+ if (bytes > limit)
101
+ throw new Error("memory_pending_file_over_budget");
102
+ chunks.push(chunk.subarray(0, bytesRead));
103
+ }
104
+ const after = await handle.stat();
105
+ const currentDirectory = await pendingReadDirectory(agentDir);
106
+ const current = await lstat(path);
107
+ if (currentDirectory.directory !== directory || version(after) !== version(opened) || version(current) !== version(opened)) {
108
+ throw new Error("memory_pending_source_changed");
109
+ }
110
+ const buffer = Buffer.concat(chunks);
111
+ let parsed;
112
+ try {
113
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
114
+ parsed = parsePendingMemoryCandidate(JSON.parse(raw));
115
+ }
116
+ catch {
117
+ throw new Error("memory_pending_file_invalid");
118
+ }
119
+ if (`${parsed.pendingId}.json` !== name)
120
+ throw new Error("memory_pending_id_invalid");
121
+ return { candidate: parsed, bytes, sha256: createHash("sha256").update(buffer).digest("hex") };
122
+ }
123
+ finally {
124
+ await handle.close();
125
+ }
126
+ }