@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 +14 -10
- package/dist/agent-memory/policy.js +3 -1
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +3 -2
- package/dist/local-executor.js +30 -9
- package/dist/local-memory/pending.js +84 -34
- package/dist/local-memory/prompt.js +11 -13
- package/dist/local-memory/read-cli.js +8 -57
- package/dist/local-memory/runtime.js +2 -2
- package/dist/local-memory/write.js +48 -16
- package/dist/main.js +0 -0
- package/dist/native-thread-context.js +59 -0
- package/dist/prompt.js +15 -15
- package/dist/runner.js +14 -15
- package/dist/runtimes/claude-session-start-hook.js +24 -0
- package/dist/runtimes/claude-thread-runner.js +266 -0
- package/dist/runtimes/claude.js +23 -4
- package/dist/runtimes/codex-app-server-runner.js +43 -4
- package/dist/runtimes/kimi-acp-runner.js +6 -2
- package/dist/runtimes/opencode-runner.js +7 -1
- package/dist/serve.js +7 -8
- package/dist/session.js +4 -0
- package/dist/supervised-runtime.js +11 -16
- package/dist/thread-input-delivery.js +217 -0
- package/package.json +10 -11
- package/dist/local-memory/candidate.js +0 -43
- package/dist/local-memory/pending-match.js +0 -29
- package/dist/local-memory/pending-query.js +0 -100
- package/dist/local-memory/pending-schema.js +0 -29
- package/dist/local-memory/pending-source.js +0 -126
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`
|
|
248
|
-
| Hermes | ACP over stdio | `session/prompt` | `session/resume` (`session/load`
|
|
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
|
|
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.
|
|
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).
|
|
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
|
|
295
|
-
|
|
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
|
-
|
|
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.
|
|
203
|
+
maintenancePurpose: z.enum(["self_training", "memory_prune"]).optional(),
|
|
204
204
|
}).strict(),
|
|
205
205
|
runtime: z.object({
|
|
206
206
|
name: RuntimeSchema,
|
package/dist/execution-runner.js
CHANGED
|
@@ -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:
|
|
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
|
-
...(
|
|
895
|
+
...(spec.instructions.maintenancePurpose === undefined ? {} : { maintenancePurpose: spec.instructions.maintenancePurpose }),
|
|
895
896
|
localMemoryV2Enabled,
|
|
896
897
|
};
|
|
897
898
|
const result = await Promise.race([
|
package/dist/local-executor.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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:
|
|
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:
|
|
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 {
|
|
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 =
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
.
|
|
154
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
228
|
+
for (const item of candidates) {
|
|
176
229
|
const change = item.candidate;
|
|
177
230
|
const prefix = `\n### ${change.title}\n`
|
|
178
|
-
+ `-
|
|
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
|
|
186
|
-
const
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
return { systemPrompt, resultPath: input.resultPath, wakePromptSuffix:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
176
|
-
const metadata = {
|
|
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:
|
|
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,
|
|
265
|
-
|
|
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 && /^
|
|
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
|
|
73
|
-
|
|
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. */
|