@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.1

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 (238) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +372 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +341 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. package/presets/edge.patch.yml +557 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * file-logger — M6 durable file exporter for Cordis `ctx.logger`.
3
+ *
4
+ * DSH host does not ship a service logger (`dsh --help` has no `--log-file`),
5
+ * and Cordis's LoggerService only installs an in-memory ring buffer (1000
6
+ * entries). Without an external exporter every `ctx.logger.info(...)` call
7
+ * lands in that buffer and is lost on exit. We register a file exporter so
8
+ * every dsh-edge-agent plugin's log output is durably persisted to disk,
9
+ * rotated on size, and tail-able for offline troubleshooting.
10
+ *
11
+ * Cordis `export()` contract is **strictly synchronous** (see
12
+ * `@deepseek-ai/cordis@4.0.1/src/logger.ts:46`; the loop at `:154-159`
13
+ * does not await) — the only safe pattern is to synchronously push the
14
+ * formatted line into an in-memory queue, then let a background
15
+ * `setInterval().unref()` flush the queue to disk. The audit-log plugin
16
+ * uses the same pattern (`audit-log.ts:147-152`).
17
+ *
18
+ * Loop avoidance: `export()` must NEVER call `ctx.logger.error(...)` —
19
+ * audit-log's own diagnostics already do (`audit-log.ts:266/309`), and a
20
+ * failure here would re-enter our own `export()` and spin. Failures are
21
+ * swallowed silently and counted; a stderr notice is emitted on the first
22
+ * drop and every 1000th drop after that.
23
+ *
24
+ * Append-mode (`fs.open(path, 'a')`) means each process boot appends to
25
+ * the existing file instead of truncating it, so install.sh's truncate-on-
26
+ * restart semantics are no longer in play — dsh-edge-agent's own writer
27
+ * owns the file lifecycle.
28
+ *
29
+ * ═══════════════════════════════════════════════════════════════════════
30
+ * OPERATIONS / AUDIT CHANNEL DISCIPLINE (2026-09-01)
31
+ * ═══════════════════════════════════════════════════════════════════════
32
+ * Two log channels. They serve different consumers. Do not blur them.
33
+ *
34
+ * ┌─ OPERATIONS LOG ──────────────────────────────────────────────────┐
35
+ * │ Path: ~/.dsh-edge-agent/log/dsh-edge-agent.log (this file) │
36
+ * │ Source: every `ctx.logger.{info,warn,error,debug}` call │
37
+ * │ Rule: **info / warn / error entries MUST NOT contain request │
38
+ * │ bodies, response bodies, tool arguments, prompt text, │
39
+ * │ or any user PII.** Only metadata (provider, model, │
40
+ * │ reqId, durationMs, counts) and error diagnostics. │
41
+ * │ │
42
+ * │ **debug entries MAY carry LLM call bodies** (request │
43
+ * │ messages + response text) for offline triage — opt-in │
44
+ * │ via `DSH_EDGE_LOG_LEVEL=debug`. 2026-09-11 default │
45
+ * │ flipped from `debug` to `info` (users said log was too │
46
+ * │ noisy — every LLM call landed 4 body lines). Re-enable │
47
+ * │ with `DSH_EDGE_LOG_LEVEL=debug`. CLOUD prompts are │
48
+ * │ pre-sanitized by input-guard before egress, so the │
49
+ * │ captured prompt is already placeholder-text for │
50
+ * │ sensitive spans. CLOUD response text is NOT redacted. │
51
+ * │ Call sites: cloud-llm-adapter.ts, local-llm-adapter.ts. │
52
+ * │ Used by operators to triage service flow, failures, and │
53
+ * │ LLM call replay. │
54
+ * └───────────────────────────────────────────────────────────────────┘
55
+ *
56
+ * ┌─ AUDIT LOG ───────────────────────────────────────────────────────┐
57
+ * │ Path: ~/.dsh-edge-agent/audit/audit.plain.jsonl │
58
+ * │ Source: every `auditLog.log({...})` call — explicit, opt-in │
59
+ * │ Rule: **Records the actual call content** (provider, model, │
60
+ * │ promptLen, messageCount, toolName, redacted args, │
61
+ * │ durationMs). Fields are PII-redacted via │
62
+ * │ `pii-utils.redactObject()` before write. Used by │
63
+ * │ compliance / legal / security review. │
64
+ * └───────────────────────────────────────────────────────────────────┘
65
+ *
66
+ * Concretely: every existing `ctx.logger.info(...)` call in dsh-edge-agent
67
+ * carries only metadata. **Before adding a new ctx.logger call**, confirm
68
+ * it does not embed any of: `messages`, `args` (tool args), `promptText`,
69
+ * response bodies, session content, or user-supplied strings. If you need
70
+ * to log that data, route it through `auditLog.log(...)` so it lands in
71
+ * the audit JSONL with field-level redaction, NOT here.
72
+ *
73
+ * Wiring (2026-09-01): file-logger is registered as a standalone Cordis
74
+ * plugin id (`profiles/dsh-edge-agent/cordis.patch.yml`) so its apply()
75
+ * runs at boot — before audit-log / http-api / etc. The disabled
76
+ * aggregator `dsh-edge-agent` (see cordis.patch.yml:26-29) means the
77
+ * internal `src/dsh-adapter/index.ts:apply()` is never invoked, so we
78
+ * can't rely on it to install the exporter. Each plugin id in the patch
79
+ * is loaded independently via its `name:` field.
80
+ *
81
+ * @module dsh-edge-agent/file-logger
82
+ */
83
+ import { Service } from '@deepseek-ai/cordis';
84
+ import { mkdir, open, stat } from 'node:fs/promises';
85
+ import { renameWithRetry } from './local-storage.js';
86
+ import { loadEdgeConfig } from './edge-config.js';
87
+ import { existsSync } from 'node:fs';
88
+ import { dirname, join } from 'node:path';
89
+ import Schema from '@deepseek-ai/schemastery';
90
+ export const name = 'file-logger';
91
+ /**
92
+ * 无硬依赖(§7.169,2026-10-31)。
93
+ *
94
+ * 原来是 `inject = ['web']`,只为了一句 boot 诊断(web-diag)能读
95
+ * `ctx.web.searchProviders.size`。代价是**整个 ops log 通道被一个无关服务
96
+ * 卡住**:cordis 要等 `web` 服务就绪才让本插件 apply,而**注册文件导出器的
97
+ * 正是本插件** ⇒ 凡是比 file-logger 更早 apply 的插件,它的启动日志(乃至
98
+ * 拒绝行)会**静默丢失**。
99
+ *
100
+ * 实测(§7.169,headless `dsh-edge-agent` profile,探针实测):
101
+ * - 守卫 apply 那一刻 `ctx.logger` 还没有落盘导出器;
102
+ * - 同一 ctx 上 `ctx.logger.info(...)` 在 t0 **丢失**,t300 / t1500 / t4000
103
+ * 才落盘 —— 即「闸门上膛」启动行与拒绝行都可能无处可落。
104
+ *
105
+ * 改法:`web` 从硬依赖降为**延迟注入**(`ctx.inject(['web'], cb)`,与
106
+ * edge-context-view / input-guard 同款纪律),本插件零静态依赖 ⇒ 导出器注册
107
+ * 不再被无关服务拖后。**注意**:实测同层插件的 apply 先后由模块解析速度
108
+ * 决定、不靠配置树顺序(§7.169),所以「通道就位」不能靠"谁先 apply"保证 ——
109
+ * 消费方要用 `ctx.inject(['fileLogger'], …)` 延迟注入,回调触发时导出器必已
110
+ * 注册(cordis `_updateState` 只在 providing fiber 变 ACTIVE 后 notify)。
111
+ *
112
+ * 注意:桌面版 ops log 的文件名(`dsh-edge-agent-desktop.log`)来自**行**
113
+ * 的 config,本插件行必须比裸包名聚合行先装载 —— 零依赖后这个顺序只会更稳。
114
+ */
115
+ export const inject = [];
116
+ /** Numeric Cordis logger level (mirrors @deepseek-ai/cordis/src/logger.ts:22-27). */
117
+ const LEVEL_NUMERIC = {
118
+ error: 0,
119
+ info: 1,
120
+ warn: 2,
121
+ debug: 3,
122
+ };
123
+ /** Reverse lookup for `resolveLoggerConfig` env validation. */
124
+ function parseLogLevel(value, fallback = 'info') {
125
+ if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error')
126
+ return value;
127
+ return fallback;
128
+ }
129
+ // §7.102 "one config file": root/level/maxFileAgeDays/maxRotatedFiles carry
130
+ // NO Schema default — resolveLoggerConfig()/apply() fill them from
131
+ // config/edge.json (audit.*) with the env params as per-process overrides.
132
+ // (The old Schema default maxRotatedFiles=5 also silently disagreed with
133
+ // the shipped value 7 — defaults living in two places always drift.)
134
+ export const Config = Schema.object({
135
+ root: Schema.string(),
136
+ filename: Schema.string().default('dsh-edge-agent.log'),
137
+ flushIntervalMs: Schema.natural().default(1000),
138
+ queueCapacity: Schema.natural().default(10_000),
139
+ maxFileSizeBytes: Schema.natural().default(50 * 1024 * 1024), // 50MB
140
+ maxFileAgeDays: Schema.number(), // §7.56: daily rotation by default
141
+ maxRotatedFiles: Schema.natural(),
142
+ level: Schema.union(['debug', 'info', 'warn', 'error']),
143
+ });
144
+ /**
145
+ * Singleton service exposed on `ctx.fileLogger`. Owns the in-memory queue
146
+ * and the background flush loop. The Cordis exporter callback installed
147
+ * by `installFileLogger` simply forwards to `export()` on this service.
148
+ */
149
+ export class FileLoggerServiceImpl extends Service {
150
+ filePath;
151
+ flushIntervalMs;
152
+ queueCapacity;
153
+ maxFileSizeBytes;
154
+ maxFileAgeDays;
155
+ maxRotatedFiles;
156
+ /** Formatted lines waiting to be flushed to disk. */
157
+ queue = [];
158
+ /** Number of entries dropped because `queue` was full. */
159
+ droppedCount = 0;
160
+ /** Monotonically increasing serial so we can debug dropped-entry runs. */
161
+ flushSerial = 0;
162
+ flushTimer;
163
+ flushPromise = Promise.resolve();
164
+ constructor(ctx, config) {
165
+ super(ctx, 'fileLogger');
166
+ this.filePath = join(config.root, config.filename);
167
+ this.flushIntervalMs = config.flushIntervalMs;
168
+ this.queueCapacity = config.queueCapacity;
169
+ this.maxFileSizeBytes = config.maxFileSizeBytes;
170
+ this.maxFileAgeDays = config.maxFileAgeDays;
171
+ this.maxRotatedFiles = config.maxRotatedFiles;
172
+ }
173
+ /** Test helper returning the resolved log file path. */
174
+ filePathForTests() {
175
+ return this.filePath;
176
+ }
177
+ /** Test helper: how many entries have been dropped because the queue was full. */
178
+ droppedCountForTests() {
179
+ return this.droppedCount;
180
+ }
181
+ /** Start the periodic flush; idempotent. Call once from `installFileLogger`. */
182
+ start() {
183
+ if (this.flushTimer !== undefined)
184
+ return;
185
+ this.flushTimer = setInterval(() => {
186
+ void this.flush().catch(() => { });
187
+ }, this.flushIntervalMs);
188
+ // unref so the timer never holds the process open by itself.
189
+ if (typeof this.flushTimer.unref === 'function')
190
+ this.flushTimer.unref();
191
+ }
192
+ /** Stop the periodic flush; for tests and the dispose hook. */
193
+ stop() {
194
+ if (this.flushTimer !== undefined) {
195
+ clearInterval(this.flushTimer);
196
+ this.flushTimer = undefined;
197
+ }
198
+ }
199
+ /**
200
+ * Cordis exporter callback. **Synchronous, no await**. Push the
201
+ * formatted line onto the queue; the background timer drains it.
202
+ *
203
+ * NEVER call `this.ctx.logger.*` from here — audit-log does that on
204
+ * its own errors (`audit-log.ts:266/309`), which would re-enter this
205
+ * method and spin the loop. We swallow everything silently and count
206
+ * drops; a one-shot stderr notice flags the first drop and every
207
+ * 1000th drop after.
208
+ */
209
+ export(message) {
210
+ try {
211
+ const line = formatLine(message);
212
+ if (this.queue.length >= this.queueCapacity) {
213
+ this.queue.shift(); // drop oldest
214
+ this.droppedCount++;
215
+ if (this.droppedCount === 1 || this.droppedCount % 1000 === 0) {
216
+ // Direct stderr write — bypasses ctx.logger to avoid re-entry.
217
+ process.stderr.write(`[file-logger] dropped ${this.droppedCount} entries (queue full, capacity=${this.queueCapacity})\n`);
218
+ }
219
+ }
220
+ this.queue.push(line);
221
+ }
222
+ catch {
223
+ // Intentionally empty — see method-level comment about loop avoidance.
224
+ }
225
+ }
226
+ /**
227
+ * Drain the queue to disk. Serialised through `flushPromise` so
228
+ * overlapping calls (timer + dispose) don't interleave writes.
229
+ *
230
+ * Uses `fs.open(path, 'a')` rather than audit-log's read-merge-write
231
+ * pattern — there is no upstream reader modifying the file, so
232
+ * append-at-the-tail is sufficient.
233
+ */
234
+ async flush() {
235
+ if (this.queue.length === 0)
236
+ return;
237
+ this.flushPromise = this.flushPromise.then(async () => {
238
+ // Snapshot the queue once; subsequent `export()` calls accumulate
239
+ // into the next batch. splice() returns the drained lines in order.
240
+ const drained = this.queue.splice(0, this.queue.length);
241
+ if (drained.length === 0)
242
+ return;
243
+ const serial = ++this.flushSerial;
244
+ try {
245
+ await mkdir(dirname(this.filePath), { recursive: true });
246
+ await this.checkAndRotate();
247
+ const payload = drained.join('');
248
+ const fd = await open(this.filePath, 'a');
249
+ try {
250
+ await fd.write(payload);
251
+ }
252
+ finally {
253
+ await fd.close();
254
+ }
255
+ }
256
+ catch {
257
+ // Swallow — never call ctx.logger. Best-effort persistence.
258
+ process.stderr.write(`[file-logger] flush #${serial} failed for ${drained.length} lines\n`);
259
+ }
260
+ });
261
+ return this.flushPromise;
262
+ }
263
+ /**
264
+ * Size + age based rotation. Copied from audit-log's
265
+ * `checkAndRotate` (`audit-log.ts:278-311`) — the algorithm is the same
266
+ * but this exporter has no read-merge-write coupling, so we only rotate
267
+ * here (audit-log couples rotation with its write path).
268
+ */
269
+ async checkAndRotate() {
270
+ if (!existsSync(this.filePath))
271
+ return;
272
+ try {
273
+ const stats = await stat(this.filePath);
274
+ // §7.56: calendar-day diff in LOCAL time (matches formatLocalIso's
275
+ // timestamp policy — "one day" is the operator's wall clock day, not
276
+ // a UTC day). The previous elapsed-time form (`now - mtime`) never
277
+ // fired for an active append-mode log because every write refreshes
278
+ // mtime to "moments ago".
279
+ const dayDiff = localDayIndex(new Date()) - localDayIndex(new Date(stats.mtimeMs));
280
+ const needsRotation = (this.maxFileSizeBytes > 0 && stats.size >= this.maxFileSizeBytes) ||
281
+ (this.maxFileAgeDays > 0 && dayDiff >= this.maxFileAgeDays);
282
+ if (!needsRotation)
283
+ return;
284
+ // Shift .N → .N+1, then current → .1. Highest index first so we
285
+ // never overwrite a file we still need to move.
286
+ for (let i = this.maxRotatedFiles; i >= 1; i--) {
287
+ const from = i === 1 ? this.filePath : `${this.filePath}.${i - 1}`;
288
+ const to = `${this.filePath}.${i}`;
289
+ if (existsSync(from)) {
290
+ await renameWithRetry(from, to);
291
+ }
292
+ }
293
+ // Delete files beyond the keep window.
294
+ const oldest = `${this.filePath}.${this.maxRotatedFiles + 1}`;
295
+ if (existsSync(oldest)) {
296
+ const { rm } = await import('node:fs/promises');
297
+ await rm(oldest, { force: true });
298
+ }
299
+ }
300
+ catch {
301
+ // Rotation failure must not block the flush path — swallow.
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Format a Cordis Message as one physical line:
307
+ * `<iso-ts> <LEVEL> [<plugin-name>] <message>\n`
308
+ *
309
+ * Mirrors the format the previous stdout exporter used
310
+ * (`src/dsh-adapter/index.ts:80-88` before Step 1.5), so operator grep
311
+ * habits (`grep '\[http-api\]' ~/.dsh-edge-agent/log/...`) carry over.
312
+ *
313
+ * Timestamp policy (§7.30): local-time ISO 8601 with numeric offset
314
+ * (e.g. `2026-09-03T09:41:30.824+08:00`), NOT `toISOString()` UTC `Z`.
315
+ * Operators in Asia/Shanghai tailing the ops log need wall-clock matches
316
+ * with their chat logs and `date` output. Epoch value is unchanged —
317
+ * any reader (jq, awk, datalog) can still parse the absolute moment.
318
+ * The audit log keeps epoch ms (machine-only schema; no human readers).
319
+ *
320
+ * Newlines inside an Error's `stack` are escaped to `⏎` so each log
321
+ * entry stays a single physical line; without this, a thrown Error
322
+ * spreads across 5-10 stack frames and `split('\n').filter(Boolean)`
323
+ * downstream counts them as separate entries, breaking rotation
324
+ * arithmetic and grep counts.
325
+ */
326
+ /**
327
+ * Calendar-day index in the system's LOCAL timezone: whole days since the
328
+ * epoch as seen from local wall-clock time. Only differences between two
329
+ * indexes are meaningful (`dayDiff >= maxFileAgeDays`). Using local
330
+ * components keeps day boundaries aligned with the log's own local ISO
331
+ * timestamps (§7.30 policy), so rotation happens at the operator's
332
+ * midnight, not UTC midnight.
333
+ */
334
+ function localDayIndex(d) {
335
+ return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000);
336
+ }
337
+ function formatLocalIso(d) {
338
+ // Manual ISO 8601 in system TZ with offset, since `toISOString()` is
339
+ // hardcoded UTC and `Intl.DateTimeFormat` is locale-aware (varies by
340
+ // runtime). getXxx() methods on Date already use system local time.
341
+ // getTimezoneOffset returns minutes WEST of UTC; flip sign so positive
342
+ // offsets are east of UTC (ISO convention).
343
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
344
+ const padMs = (n) => String(n).padStart(3, '0');
345
+ const offsetMin = -d.getTimezoneOffset();
346
+ const sign = offsetMin >= 0 ? '+' : '-';
347
+ const absMin = Math.abs(offsetMin);
348
+ const offH = pad(Math.floor(absMin / 60));
349
+ const offM = pad(absMin % 60);
350
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
351
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
352
+ `.${padMs(d.getMilliseconds())}${sign}${offH}:${offM}`);
353
+ }
354
+ function formatLine(message) {
355
+ const ts = formatLocalIso(new Date(message.ts));
356
+ const type = message.type.toUpperCase().padEnd(5);
357
+ const args = message.args ?? [];
358
+ const head = args[0] instanceof Error
359
+ ? (args[0].stack || args[0].message)
360
+ : String(args[0] ?? '');
361
+ const tail = args.slice(1)
362
+ .map((a) => (a instanceof Error ? a.message : String(a)))
363
+ .join(' ');
364
+ const line = tail.length > 0 ? `${head} ${tail}` : head;
365
+ // Collapse internal newlines (mainly from Error.stack frames) so each
366
+ // entry is exactly one physical line. `⏎` is a visible marker that
367
+ // round-trips cleanly through `tail -F` and grep.
368
+ return `${ts} ${type} [${message.name}] ${line.replace(/\r?\n/g, ' ⏎ ')}\n`;
369
+ }
370
+ /**
371
+ * Resolve the file-logger config from env vars with sensible defaults.
372
+ * Centralised here so tests can call it directly and `installFileLogger`
373
+ * stays a one-liner.
374
+ *
375
+ * Path resolution: `DSH_EDGE_LOG_ROOT` wins, then `USERPROFILE` (Windows),
376
+ * then `HOME` (POSIX). File name is fixed (`dsh-edge-agent.log`) so
377
+ * operators always know what file to tail.
378
+ *
379
+ * Level resolution: `DSH_EDGE_LOG_LEVEL` wins, otherwise `info`. The
380
+ * `info` default (2026-09-11, flipped from `debug` per user feedback
381
+ * "log 太冗余") suppresses LLM body debug calls in
382
+ * `cloud-llm-adapter.ts` / `local-llm-adapter.ts` — set
383
+ * `DSH_EDGE_LOG_LEVEL=debug` to re-enable.
384
+ */
385
+ export function resolveLoggerConfig(env = process.env) {
386
+ // §7.102 layering (per field): explicit env param entry > config/edge.json
387
+ // `audit.*` (loadEdgeConfig also folds live DSH_EDGE_* env into the file
388
+ // view) > the literal below. The env-param layer keeps the unit tests'
389
+ // fake-env surface working; the file layer is what the desktop GUI finally
390
+ // gets when no env is exported.
391
+ const a = loadEdgeConfig(true).audit ?? {};
392
+ const homeBase = (env.USERPROFILE ?? env.HOME ?? '').toString();
393
+ const root = firstNonEmpty(env.DSH_EDGE_LOG_ROOT, a.logRoot)
394
+ ?? join(homeBase, '.dsh-edge-agent', 'log');
395
+ // §7.56: daily rotation is the default (maxFileAgeDays=1). Override with
396
+ // DSH_EDGE_LOG_MAX_AGE_DAYS (0 disables age rotation, keeping size-only).
397
+ const ageDaysEnv = parseInt(env.DSH_EDGE_LOG_MAX_AGE_DAYS ?? '', 10);
398
+ const rotatedEnv = parseInt(env.DSH_EDGE_LOG_MAX_ROTATED ?? '', 10);
399
+ return {
400
+ root,
401
+ filename: 'dsh-edge-agent.log',
402
+ flushIntervalMs: 1000,
403
+ queueCapacity: 10_000,
404
+ maxFileSizeBytes: 50 * 1024 * 1024,
405
+ maxFileAgeDays: Number.isFinite(ageDaysEnv) && ageDaysEnv >= 0
406
+ ? ageDaysEnv
407
+ : a.logMaxAgeDays ?? 1,
408
+ maxRotatedFiles: Number.isFinite(rotatedEnv) && rotatedEnv > 0
409
+ ? rotatedEnv
410
+ : a.logMaxRotated ?? 7,
411
+ level: parseLogLevel(env.DSH_EDGE_LOG_LEVEL ?? a.logLevel, 'info'),
412
+ };
413
+ }
414
+ /** First non-empty string among the candidates, else undefined. */
415
+ function firstNonEmpty(...values) {
416
+ for (const v of values) {
417
+ if (typeof v === 'string' && v.length > 0)
418
+ return v;
419
+ }
420
+ return undefined;
421
+ }
422
+ /**
423
+ * Install the file exporter on a fresh Cordis context. Called once at
424
+ * the very top of `apply()` (`src/dsh-adapter/index.ts`) before any
425
+ * other plugin can call `ctx.logger.*`.
426
+ *
427
+ * Gating:
428
+ * - `DSH_EDGE_LOG_FILE=false` disables (tests opt out).
429
+ * - `DSH_EDGE_LOG_FILE=force` 仍被接受(历史兼容),但已是默认行为。
430
+ *
431
+ * §7.118 (2026-10-26) — 守卫语义修正:
432
+ *
433
+ * 旧守卫是 `ctx.logger.exporters.size > 1 → return undefined`,理由是
434
+ * "宿主若已注册 file exporter,我们再写一份就是双写"。但这个 size 启发式
435
+ * **分不出控制台 exporter 和文件 exporter**:
436
+ *
437
+ * - 桌面 App 的 dsh-desktop-host 注册的是**控制台** exporter(输出走
438
+ * Electron 的 stdout,不落盘 —— `~/Library/Logs/DeepSeek Harness/`
439
+ * 实测是空目录)。size 变成 2 → 我们跳过 → **桌面 ops log 整份消失**,
440
+ * 最后一次写入停在 2026-10-25 19:47。
441
+ * - CLI profile 同理(这正是 `DSH_EDGE_LOG_FILE=force` 逃逸口存在的原因,
442
+ * 见本文件下方的历史注释)。
443
+ *
444
+ * 也就是说,这个守卫防的是一个**不会发生**的碰撞(两个 exporter 写同一个
445
+ * 文件),代价却是让 ops log 在两种真实宿主里都从不落盘。而"会不会写坏
446
+ * 文件"根本不成立:我们的输出路径是 **edge 独占**的
447
+ * `~/.dsh-edge-agent/log/`,宿主没有任何理由往那儿写。
448
+ *
449
+ * 真正需要防的"我们自己被装载两次"由下面的重复 `provide` try/catch 负责,
450
+ * 不该由 size 启发式兜。
451
+ *
452
+ * 现在:除非显式 `false`,一律注册。
453
+ */
454
+ export function installFileLogger(ctx, resolved) {
455
+ if (process.env.DSH_EDGE_LOG_FILE === 'false')
456
+ return undefined;
457
+ // 历史上的 `force` 逃逸口:CLI profile 的宿主也注册了自己的 exporter,
458
+ // 旧守卫会让 ops log 静默不落盘。§7.118 起默认就注册,`force` 保留为
459
+ // 无操作别名,避免老脚本里的 `DSH_EDGE_LOG_FILE=force` 变成未知值。
460
+ const config = resolved ?? resolveLoggerConfig();
461
+ // Duplicate-load tolerance: in patch profiles the file-logger ROW applies
462
+ // first (owns the service name), and the bare aggregator row's apply then
463
+ // calls installFileLogger a SECOND time — `new Service` throws on the
464
+ // duplicate provide ("service fileLogger has been registered"), which
465
+ // used to kill the whole aggregator entry ("1 entry did not activate",
466
+ // observed on the headless dsh-edge-agent profile 2026-10-24). With the
467
+ // instance already provided, its exporter is already capturing — skip.
468
+ let service;
469
+ try {
470
+ service = new FileLoggerServiceImpl(ctx, config);
471
+ }
472
+ catch (err) {
473
+ process.stderr.write(`[file-logger] skip duplicate install: ${err?.message ?? String(err)}\n`);
474
+ return undefined;
475
+ }
476
+ service.start();
477
+ // 2026-09-02 diagnostic: dump the web seam provider registry once boot
478
+ // settles. The "configured web provider deepseek-official is not
479
+ // registered" error we hit on `web_search` tool calls means
480
+ // web-search-deepseek's `apply()` never ran its
481
+ // `ctx.web.registerSearchProvider(...)` line — either because its
482
+ // `inject: ['web']` dep wasn't satisfied, or because
483
+ // `installSettingsSection` threw first. Knowing which one at boot saves
484
+ // a 90s chat round-trip.
485
+ //
486
+ // §7.169: `web` 是**延迟注入**(不再是本插件的硬依赖)—— 见本文件
487
+ // `export const inject` 上方的说明:硬依赖会把整个 ops log 通道拖到
488
+ // `web` 就绪之后,早 apply 的插件日志会静默丢失。延迟注入的回调在
489
+ // `web` 出现时触发;没有 `web` 的 profile 就不打这一行(与原行为等价:
490
+ // 原来是「读不到就打印 failed」)。
491
+ ctx.inject(['web'], (raw) => {
492
+ try {
493
+ const web = raw?.web;
494
+ const map = web?.searchProviders;
495
+ const size = map instanceof Map ? map.size : -1;
496
+ const ids = map instanceof Map ? [...map.keys()].join(',') : 'n/a';
497
+ ctx.logger?.info?.(`[web-diag] ctx.web registered=${web !== undefined} searchProviders.size=${size} ids=[${ids}]`);
498
+ }
499
+ catch (err) {
500
+ ctx.logger?.warn?.(`[web-diag] failed: ${err?.message ?? String(err)}`);
501
+ }
502
+ });
503
+ // Bind the export callback to the service instance. Cordis calls this
504
+ // synchronously from inside its logger loop — never invoke ctx.logger
505
+ // from inside (would re-enter and loop).
506
+ //
507
+ // 2026-09-02: pass `levels: { default: <numeric> }` so the file-logger
508
+ // receives all messages at-or-above the configured threshold. Without
509
+ // this, Cordis's per-exporter level defaults to INFO and silently
510
+ // suppresses every `ctx.logger.debug(...)` call — including the LLM
511
+ // body debug calls in cloud-llm-adapter.ts / local-llm-adapter.ts.
512
+ // Cordis's own ring-buffer exporter keeps its hard-coded INFO level so
513
+ // the in-memory buffer stays tight while disk captures everything.
514
+ ctx.logger.exporter({
515
+ export: (msg) => service.export(msg),
516
+ levels: { default: LEVEL_NUMERIC[config.level] },
517
+ });
518
+ return service;
519
+ }
520
+ /**
521
+ * Stop the flush timer and drain the queue. Wired into the apply()
522
+ * dispose hook in `index.ts` so a clean shutdown loses at most one
523
+ * interval's worth of logs.
524
+ */
525
+ export async function shutdownFileLogger(service) {
526
+ if (!service)
527
+ return;
528
+ service.stop();
529
+ await service.flush();
530
+ }
531
+ /**
532
+ * Standard Cordis plugin apply — installs the file exporter on first
533
+ * load and returns a disposer that drains the queue on shutdown.
534
+ *
535
+ * Wired into repo-root `cordis.patch.yml` (per §7.25 + §7.31 — bundle is
536
+ * self-contained) as a standalone plugin id `file-logger` so this runs
537
+ * at boot, before any other dsh-edge-agent plugin (audit-log / http-api /
538
+ * etc.) emits log lines.
539
+ *
540
+ * Idempotency: `installFileLogger` already has a first-wins guard via
541
+ * `ctx.logger.exporters.size > 1`. Calling it twice is a no-op.
542
+ */
543
+ export async function apply(ctx, config) {
544
+ // §7.102: the cordis row may omit root/level/rotation fields (the patches
545
+ // no longer pass env expressions). Layer: explicit row value >
546
+ // resolveLoggerConfig() (= config/edge.json audit.* > env > literal).
547
+ const base = resolveLoggerConfig();
548
+ const row = config;
549
+ const pick = (k) => (row[k] === undefined ? base[k] : row[k]);
550
+ const effective = {
551
+ root: pick('root'),
552
+ filename: pick('filename'),
553
+ flushIntervalMs: pick('flushIntervalMs'),
554
+ queueCapacity: pick('queueCapacity'),
555
+ maxFileSizeBytes: pick('maxFileSizeBytes'),
556
+ maxFileAgeDays: pick('maxFileAgeDays'),
557
+ maxRotatedFiles: pick('maxRotatedFiles'),
558
+ level: pick('level'),
559
+ };
560
+ const service = installFileLogger(ctx, effective);
561
+ return async () => {
562
+ await shutdownFileLogger(service);
563
+ };
564
+ }