@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
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.
@@ -354,6 +358,15 @@ Runtime launch, which recovers from a missed notification or daemon restart. Ski
354
358
  are visible through the symlink immediately; adding, deleting, renaming, or changing frontmatter requires
355
359
  a rescan. Web descriptions remain at the last inventory value until that rescan.
356
360
 
361
+ ## 目录投影测试的平台范围
362
+
363
+ `test/directory-projection.test.ts` 在所有平台运行,覆盖路径检查、复制发布、只读权限请求及失败恢复。
364
+ 其受控文件系统夹具记录目录 `chmod` 请求,但保留宿主目录的写权限,避免把 macOS 的目录重命名规则当作 Windows 行为。
365
+ 生产代码的只读保护不受此夹具影响。
366
+
367
+ `test/directory-projection.windows.test.ts` 仅在 Windows 上运行,使用真实文件系统验证只读目录发布与文件保护。
368
+ 非 Windows 的全量测试会明确跳过该文件,不能据此宣称 Windows 原生行为已验证。
369
+
357
370
  ## Reliability
358
371
 
359
372
  Protocol-v1 lifecycle is `accepted → started → completed`, followed by a server
@@ -418,6 +431,28 @@ Local policy can reduce server-requested access and limits; it cannot grant more
418
431
  Provider credentials and configured environment are prepared locally and never carried in execution
419
432
  control frames.
420
433
 
434
+ ## 完整本地记忆读取
435
+
436
+ 运行时注入的 `MEMORY.md` 是有界预览,未命中不代表完整文件没有相关记忆。
437
+ 普通 Agent 工作区会在现有 PATH 目录中提供只读 `crew-memory` 工具,无需 Server 凭证:
438
+
439
+ ```bash
440
+ crew-memory search --query '提交前检查'
441
+ crew-memory search --query '提交前检查' --after-line 120
442
+ crew-memory read --offset 2400 --limit 2000
443
+ crew-memory read --file notes/testing.md --offset 0 --limit 2000
444
+ ```
445
+
446
+ 命令默认返回 JSON。搜索遍历完整指定文件,返回 `line`、`offset`、命中摘要和
447
+ `nextAfterLine`;读取按 Unicode 字符偏移分页,使用 `nextOffset` 继续直到 `eof=true`。
448
+ 后续页可传 `--if-hash <上一页的sha256>`,文件变化时明确停止,不混读不同版本。
449
+ 每页 JSON 有 12 KiB 上限,输入文件超过 32 MiB 时明确报错,不将未完成搜索伪装为零命中。
450
+
451
+ 工具仅支持 `MEMORY.md` 和 `notes/` 下明确指定的 Markdown 文件,不遍历 pending,
452
+ 不写入或迁移记忆。搜索结果是原始资料定位,读取明细前仍需确认当前有效索引和正文片段。
453
+ 这些修改不改变现有 V2 写入上限、候选状态或归档策略,也不提供自动语义召回保证。
454
+ 工具随 daemon 包发布;旧 daemon 没有该入口时,Agent 使用原生文件搜索与分段读取回退。
455
+
421
456
  ## Optional TencentDB Agent Memory Bridge
422
457
 
423
458
  Execution protocol v1 can opt NowWork Agents into the private TencentDB Agent Memory Panel.
@@ -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
  }
@@ -0,0 +1,83 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ const MAX_REPORT_BYTES = 32 * 1024 * 1024;
5
+ export function reportDeliveryContext(executionId, runDir, facts) {
6
+ return {
7
+ version: 1,
8
+ provider: facts.provider,
9
+ executionId,
10
+ receiptFile: join(runDir, `.qa-report-delivery-${createHash("sha256").update(executionId).digest("hex")}.json`),
11
+ ...(facts.ossUploadScript === undefined ? {} : { script: facts.ossUploadScript }),
12
+ };
13
+ }
14
+ function inside(root, file) {
15
+ const part = relative(root, file);
16
+ return part !== ".." && !part.startsWith("../") && !part.startsWith("..\\") && !isAbsolute(part);
17
+ }
18
+ export async function verifiedReportReceipt(input) {
19
+ if (!input.receiptFile || !input.answer)
20
+ return false;
21
+ try {
22
+ const stat = await lstat(input.receiptFile);
23
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
24
+ return false;
25
+ const receipt = JSON.parse(await readFile(input.receiptFile, "utf8"));
26
+ if (!receipt || typeof receipt !== "object")
27
+ return false;
28
+ const r = receipt;
29
+ if (r.version !== 1 || r.executionId !== input.executionId || r.evidenceComplete !== true
30
+ || !Array.isArray(r.runIds) || r.runIds.length === 0 || r.runIds.length > 100
31
+ || r.runIds.some(id => typeof id !== "string" || !id || id.length > 256)
32
+ || typeof r.file !== "string" || !isAbsolute(r.file) || !r.file.endsWith(".html")
33
+ || typeof r.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(r.sha256)
34
+ || typeof r.url !== "string")
35
+ return false;
36
+ const url = new URL(r.url);
37
+ if (url.protocol !== "https:" || url.hostname !== "ai-claw-in.nowcoder.com"
38
+ || !url.pathname.endsWith(".html") || url.username || url.password || url.search || url.hash)
39
+ return false;
40
+ const answerUrls = input.answer.match(/https:\/\/ai-claw-in\.nowcoder\.com\/[^\s<>"')\]]+/g) ?? [];
41
+ if (!answerUrls.includes(url.href))
42
+ return false;
43
+ const file = await realpath(r.file);
44
+ const roots = await Promise.all([input.root, input.qaReportRoot ?? join(input.root, "qa-report-server-v2")]
45
+ .map(root => realpath(resolve(root)).catch(() => null)));
46
+ if (!roots.some(root => root !== null && inside(root, file)))
47
+ return false;
48
+ const fileStat = await lstat(file);
49
+ if (!fileStat.isFile() || fileStat.size > MAX_REPORT_BYTES)
50
+ return false;
51
+ const expectedHash = createHash("sha256").update(await readFile(file)).digest("hex");
52
+ if (expectedHash !== r.sha256)
53
+ return false;
54
+ const response = await fetch(url.href, { redirect: "error", signal: AbortSignal.timeout(15_000) });
55
+ if (!response.ok || !/^text\/html(?:;|$)/i.test(response.headers.get("content-type") ?? "")
56
+ || /attachment/i.test(response.headers.get("content-disposition") ?? "") || !response.body) {
57
+ await response.body?.cancel();
58
+ return false;
59
+ }
60
+ const reader = response.body.getReader();
61
+ const hash = createHash("sha256");
62
+ let bytes = 0;
63
+ try {
64
+ while (true) {
65
+ const chunk = await reader.read();
66
+ if (chunk.done)
67
+ break;
68
+ bytes += chunk.value.byteLength;
69
+ if (bytes > MAX_REPORT_BYTES)
70
+ return false;
71
+ hash.update(chunk.value);
72
+ }
73
+ return bytes === fileStat.size && hash.digest("hex") === expectedHash;
74
+ }
75
+ finally {
76
+ await reader.cancel().catch(() => undefined);
77
+ reader.releaseLock();
78
+ }
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
@@ -4,6 +4,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { cloudReportUploadFacts } from "./cloud-report-upload.js";
6
6
  import { dslog } from "./slog.js";
7
+ import { verifiedReportReceipt } from "./cloud-report-receipt.js";
7
8
  const execFileP = promisify(execFile);
8
9
  const REPORT_FILENAMES = new Set(["report.md", "result.json"]);
9
10
  const MAX_DEPTH = 8;
@@ -13,22 +14,17 @@ const fileFingerprint = async (path) => {
13
14
  const stat = await lstat(path);
14
15
  return `${stat.size}:${stat.mtimeMs}`;
15
16
  };
16
- function isReportFile(root, path) {
17
+ function isReportFile(root, path, kind) {
17
18
  const parts = relative(root, path).split(sep).filter(Boolean);
18
19
  const filename = parts.at(-1) ?? "";
19
- if (parts[0] === "tasks" && parts.length >= 3) {
20
- return REPORT_FILENAMES.has(filename) || extname(filename).toLowerCase() === ".html";
21
- }
22
- if (parts[0] !== "qa-report-server-v2")
23
- return false;
24
- if (parts[1] === "static" && parts.length === 3)
25
- return extname(filename).toLowerCase() === ".html";
26
- if (parts[1] === "qa-results" && parts.length >= 4) {
20
+ if (kind === "static")
21
+ return parts.length === 1 && extname(filename).toLowerCase() === ".html";
22
+ if (parts.length >= 2) {
27
23
  return REPORT_FILENAMES.has(filename) || extname(filename).toLowerCase() === ".html";
28
24
  }
29
25
  return false;
30
26
  }
31
- async function walkReports(root, directory, depth, output) {
27
+ async function walkReports(root, directory, depth, output, kind) {
32
28
  if (depth > MAX_DEPTH || output.length >= MAX_FILES)
33
29
  return;
34
30
  const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
@@ -39,28 +35,26 @@ async function walkReports(root, directory, depth, output) {
39
35
  continue;
40
36
  const path = join(directory, entry.name);
41
37
  if (entry.isDirectory()) {
42
- await walkReports(root, path, depth + 1, output);
38
+ await walkReports(root, path, depth + 1, output, kind);
43
39
  }
44
- else if (entry.isFile() && isReportFile(root, path)) {
40
+ else if (entry.isFile() && isReportFile(root, path, kind)) {
45
41
  output.push(resolve(path));
46
42
  }
47
43
  }
48
44
  }
49
- export async function snapshotReportFiles(root) {
45
+ export async function snapshotReportFiles(root, qaReportRoot) {
50
46
  const files = [];
51
47
  const workspaceRoot = resolve(root);
52
- for (const directory of [
53
- join(workspaceRoot, "tasks"),
54
- join(workspaceRoot, "qa-report-server-v2", "qa-results"),
55
- join(workspaceRoot, "qa-report-server-v2", "static"),
56
- ]) {
57
- await walkReports(workspaceRoot, directory, 0, files);
48
+ const hub = resolve(qaReportRoot ?? join(workspaceRoot, "qa-report-server-v2"));
49
+ for (const kind of ["tasks", "qa-results", "static"]) {
50
+ const directory = join(kind === "tasks" ? workspaceRoot : hub, kind);
51
+ await walkReports(directory, directory, 0, files, kind);
58
52
  }
59
53
  const entries = await Promise.all(files.map(async (path) => [path, await fileFingerprint(path)]));
60
54
  return new Map(entries);
61
55
  }
62
- export async function discoverReportFiles(root, before) {
63
- const after = await snapshotReportFiles(root);
56
+ export async function discoverReportFiles(root, before, qaReportRoot) {
57
+ const after = await snapshotReportFiles(root, qaReportRoot);
64
58
  return [...after.entries()]
65
59
  .filter(([path, fingerprint]) => before.get(path) !== fingerprint)
66
60
  .map(([path]) => path)
@@ -97,7 +91,7 @@ async function verifyOssUrl(url) {
97
91
  }
98
92
  }
99
93
  export async function uploadCloudReports(input) {
100
- const files = await discoverReportFiles(input.root, input.before);
94
+ const files = await discoverReportFiles(input.root, input.before, input.qaReportRoot);
101
95
  if (files.length === 0)
102
96
  return { urls: [], failures: [] };
103
97
  const run = input.execFile ?? (async (file, args) => {
@@ -131,10 +125,10 @@ export async function uploadCloudReports(input) {
131
125
  }
132
126
  return { urls, failures };
133
127
  }
134
- export async function prepareCloudReportSnapshot(facts, root) {
128
+ export async function prepareCloudReportSnapshot(facts, root, qaReportRoot) {
135
129
  if (!facts.enabled)
136
130
  return null;
137
- return snapshotReportFiles(root);
131
+ return snapshotReportFiles(root, qaReportRoot);
138
132
  }
139
133
  export async function prepareCloudReportExecution(input) {
140
134
  const facts = cloudReportUploadFacts(input.env);
@@ -147,7 +141,7 @@ export async function prepareCloudReportExecution(input) {
147
141
  return { facts, snapshot: null };
148
142
  }
149
143
  try {
150
- return { facts, snapshot: await prepareCloudReportSnapshot(facts, input.root) };
144
+ return { facts, snapshot: await prepareCloudReportSnapshot(facts, input.root, input.qaReportRoot) };
151
145
  }
152
146
  catch (error) {
153
147
  dslog("cloud_report_upload.snapshot_failed", "报告文件快照失败", {
@@ -173,10 +167,19 @@ export async function deliverCloudReports(input) {
173
167
  if (!input.facts.enabled || input.facts.ossUploadScript === undefined || input.before === null) {
174
168
  return { finalText: input.finalText, externalAnswer: input.externalAnswer };
175
169
  }
170
+ if (input.captureFinal && await verifiedReportReceipt({
171
+ ...input, answer: input.externalAnswer ?? input.finalText,
172
+ })) {
173
+ dslog("cloud_report_upload.already_delivered", "本次执行的 HTML 交付收据及内容已验证,跳过重复上传", {
174
+ execution_id: input.executionId, agent_handle: input.agentHandle,
175
+ });
176
+ return { finalText: input.finalText, externalAnswer: input.externalAnswer };
177
+ }
176
178
  try {
177
179
  const result = await (input.upload ?? uploadCloudReports)({
178
180
  script: input.facts.ossUploadScript,
179
181
  root: input.root,
182
+ ...(input.qaReportRoot === undefined ? {} : { qaReportRoot: input.qaReportRoot }),
180
183
  before: input.before,
181
184
  });
182
185
  if (result.failures.length > 0) {
@@ -185,6 +188,14 @@ export async function deliverCloudReports(input) {
185
188
  uploaded_count: result.urls.length, failed_count: result.failures.length,
186
189
  });
187
190
  }
191
+ else {
192
+ dslog(result.urls.length === 0 ? "cloud_report_upload.no_files" : "cloud_report_upload.completed", result.urls.length === 0 ? "未发现新增或变化的报告文件" : "报告上传完成", {
193
+ execution_id: input.executionId, agent_handle: input.agentHandle,
194
+ uploaded_count: result.urls.length,
195
+ report_root: input.root,
196
+ qa_report_root: input.qaReportRoot ?? join(input.root, "qa-report-server-v2"),
197
+ });
198
+ }
188
199
  return {
189
200
  finalText: input.captureFinal && result.urls.length > 0
190
201
  ? appendReportLinks(input.finalText ?? "", result.urls)
@@ -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,
@@ -834,13 +834,17 @@ export async function runExecution(config, input, dependencies) {
834
834
  ...(selfTraining ? { isolateLocalMemory: true } : {}),
835
835
  collaborationTools: config.codexCollaborationTools && spec.runtime.name === "codex",
836
836
  });
837
- const boundedSystemPrompt = (context) => appendAgentMemoryContext(typeof systemPromptWithLocalFacts === "string"
838
- ? systemPromptWithLocalFacts
839
- : systemPromptWithLocalFacts(context), recalledMemory, baseSystemPromptBudget);
837
+ const boundedSystemPrompt = (context) => {
838
+ const base = typeof systemPromptWithLocalFacts === "string"
839
+ ? systemPromptWithLocalFacts
840
+ : systemPromptWithLocalFacts(context);
841
+ return appendAgentMemoryContext(base, recalledMemory, Math.max(Buffer.byteLength(base, "utf8"), baseSystemPromptBudget - (context.reservedPromptBytes ?? 0)));
842
+ };
840
843
  const localInput = {
841
844
  executionId: spec.executionId,
842
845
  handle: spec.agent.handle,
843
846
  channelId: spec.context.channelId,
847
+ ...(spec.context.threadId === undefined ? {} : { threadId: spec.context.threadId }),
844
848
  keyMode: "opaque",
845
849
  taskKey: spec.workspace.taskKey,
846
850
  ...(spec.workspace.resumeKey === undefined ? {} : { resumeKey: spec.workspace.resumeKey }),
@@ -882,13 +886,13 @@ export async function runExecution(config, input, dependencies) {
882
886
  } : {}),
883
887
  },
884
888
  session: {
885
- enabled: selfTraining ? false : config.resume,
889
+ enabled: spec.instructions.maintenancePurpose !== undefined ? false : config.resume,
886
890
  warmMs: config.resumeWarmMs,
887
891
  budgetTokens: config.sessionBudgetTokens,
888
892
  softTokens: config.sessionSoftTokens,
889
893
  maxTurns: config.sessionMaxTurns,
890
894
  },
891
- ...(selfTraining ? { maintenancePurpose: "self_training" } : {}),
895
+ ...(spec.instructions.maintenancePurpose === undefined ? {} : { maintenancePurpose: spec.instructions.maintenancePurpose }),
892
896
  localMemoryV2Enabled,
893
897
  };
894
898
  const result = await Promise.race([
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { isWin } from "./platform.js";
4
+ import { augmentedPath } from "./runtime-path.js";
4
5
  import { listClaudeModels } from "./runtimes/claude-models.js";
5
6
  import { listHermesModels } from "./runtimes/hermes-models.js";
6
7
  const execFileRaw = promisify(execFile);
@@ -8,6 +9,7 @@ const MODEL_PROBE_TIMEOUT_MS = 8_000;
8
9
  const MODEL_PROBE_MAX_BUFFER_BYTES = 1024 * 1024;
9
10
  // win32 上 npm CLI 是 .cmd shim,execFile 需 shell 才能执行;参数全是固定字面量,无注入面。
10
11
  const execFileP = (bin, args) => execFileRaw(bin, args, {
12
+ env: { ...process.env, PATH: augmentedPath() },
11
13
  shell: isWin(),
12
14
  timeout: MODEL_PROBE_TIMEOUT_MS,
13
15
  killSignal: "SIGKILL",
@@ -34,45 +34,39 @@ export function truncateUtf8(value, maxBytes) {
34
34
  }
35
35
  return value.slice(0, end);
36
36
  }
37
- function takeCompletePromptSections(sections, maxBytes) {
38
- return sections.reduce((state, section) => {
39
- if (!state.full)
40
- return state;
41
- const sectionBytes = Buffer.byteLength(section, "utf8");
42
- if (state.bytes + sectionBytes > maxBytes)
43
- return { ...state, full: false };
44
- return { value: `${state.value}${section}`, bytes: state.bytes + sectionBytes, full: true };
45
- }, { value: "", bytes: 0, full: true }).value;
46
- }
47
37
  /** v1 server instructions opt in to daemon-owned paths and bounded local workspace context. */
48
38
  export function withLocalExecutionFacts(serverPrompt, maxBytes, options = {}) {
49
- return ({ workspace, resuming, projectContext, trainingProjectContext }) => {
50
- const memory = options.isolateLocalMemory ? "" : capMemoryForInject(workspace.memory);
39
+ return ({ workspace, resuming, projectContext, trainingProjectContext, reservedPromptBytes = 0 }) => {
51
40
  const workLog = resuming ? "" : capWorkLogForInject(workspace.workLog);
52
41
  const collaborationPrompt = options.collaborationTools === true ? buildCollaborationToolsPrompt() : "";
53
42
  const mandatoryWorkspacePrompt = `${buildRuntimeWorkspacePrompt(projectContext)}${buildTrainingGitProjectPrompt(trainingProjectContext)}`;
54
43
  const crewHome = options.isolateLocalMemory ? workspace.runDir : workspace.dir;
55
- const localFactSections = [`\n\n## Local execution facts
44
+ const localFacts = `\n\n## Local execution facts
56
45
  - $CREW_HOME: ${crewHome}
57
- - $CREW_TASK_LOG: ${workspace.workLogPath}`,
58
- ...(memory ? [`\n\n## Injected MEMORY.md (bounded local context)\n${memory}`] : []),
59
- ...(workLog ? [`\n\n## Injected work-log (bounded local context)\n${workLog}`] : []),
60
- ];
46
+ - $CREW_TASK_LOG: ${workspace.workLogPath}`;
61
47
  const serverPromptBytes = Buffer.byteLength(serverPrompt, "utf8");
62
48
  const collaborationBytes = Buffer.byteLength(collaborationPrompt, "utf8");
63
49
  const mandatoryBytes = Buffer.byteLength(mandatoryWorkspacePrompt, "utf8");
64
50
  const requiredBytes = serverPromptBytes + collaborationBytes + mandatoryBytes;
65
- // 必需预算与 projectContext 无关:协作段落永远参与;无协作时与旧实现逐字节等价。
51
+ // 先保留全部必需内容,再按实际剩余字节一次性构造预览。
66
52
  if (collaborationPrompt !== "" && requiredBytes > maxBytes) {
67
53
  throw new Error("collaboration_tools_prompt_budget_exceeded");
68
54
  }
69
- if (mandatoryWorkspacePrompt !== "") {
70
- if (requiredBytes > maxBytes)
71
- throw new Error("project_workspace_prompt_budget_exceeded");
72
- const optionalFacts = takeCompletePromptSections(localFactSections, maxBytes - requiredBytes);
73
- return `${serverPrompt}${collaborationPrompt}${optionalFacts}${mandatoryWorkspacePrompt}`;
55
+ if (mandatoryWorkspacePrompt !== "" && requiredBytes > maxBytes) {
56
+ throw new Error("project_workspace_prompt_budget_exceeded");
74
57
  }
75
- const remaining = maxBytes - requiredBytes;
76
- return `${serverPrompt}${collaborationPrompt}${truncateUtf8(localFactSections.join(""), remaining)}`;
58
+ const remaining = maxBytes - requiredBytes - Math.max(0, reservedPromptBytes);
59
+ const factsBytes = Buffer.byteLength(localFacts, "utf8");
60
+ if (factsBytes > remaining)
61
+ return `${serverPrompt}${collaborationPrompt}${mandatoryWorkspacePrompt}`;
62
+ const memoryHeading = "\n\n## Injected MEMORY.md (bounded local context)\n";
63
+ const memory = options.isolateLocalMemory ? "" : capMemoryForInject(workspace.memory, undefined, remaining - factsBytes - Buffer.byteLength(memoryHeading, "utf8"));
64
+ const memorySection = memory === "" ? "" : memoryHeading + memory;
65
+ const workLogSection = workLog === "" ? "" : `\n\n## Injected work-log (bounded local context)\n${workLog}`;
66
+ const workLogBudget = remaining - factsBytes - Buffer.byteLength(memorySection, "utf8");
67
+ const boundedWorkLog = mandatoryWorkspacePrompt === ""
68
+ ? truncateUtf8(workLogSection, workLogBudget)
69
+ : Buffer.byteLength(workLogSection, "utf8") <= workLogBudget ? workLogSection : "";
70
+ return `${serverPrompt}${collaborationPrompt}${localFacts}${memorySection}${boundedWorkLog}${mandatoryWorkspacePrompt}`;
77
71
  };
78
72
  }