@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,184 @@
1
+ /**
2
+ * edge-context-view — §7.101 host half of the desktop "Edge" tab.
3
+ *
4
+ * Folds the edge privacy audit stream into four panel row-lists (intent /
5
+ * route / interception / threat) plus a per-session summary (turns + tokens
6
+ * + lifetime), and serves them to the desktop client over a framework-
7
+ * standard Connection POST route (`/api/dsh-edge/context`).
8
+ *
9
+ * Data-path decision (docs/notes/dsh-context-walkthrough.md, rulings A/B/D):
10
+ * - The panel sources are audit-log records — NOT subscribable plugin
11
+ * event streams. Live folding rides the new `auditLog.onEntry` seam;
12
+ * `query()` re-reads the whole file and is unusable as a poll source.
13
+ * - SessionEvent vocabulary is closed (no custom kinds), so
14
+ * `sessionProjections` cannot carry edge privacy events. The transport
15
+ * is the Connection fetch-route (dsh-context's detail-channel pattern);
16
+ * the client pulls with a cursor while the tab is mounted.
17
+ * - `ctx.edgeContextView.snapshot()` is a plain data fold, not an
18
+ * AsyncIterable: the browser cannot subscribe to cordis services, so a
19
+ * host-side stream would be an untestable dead API. Unit tests hit the
20
+ * fold directly.
21
+ * - Turns/tokens ride the in-process `session/event` listener (same
22
+ * idiom as http-api.ts:567): turn count from `turn/start`, tokens from
23
+ * `assistant/message` `usage` when the adapter reported accounting —
24
+ * absent usage, the summary says so instead of promising numbers.
25
+ *
26
+ * PII discipline: rows are counts/categories/timestamps/levels only at the
27
+ * panel level; before a payload is stored, every string value passes the
28
+ * `pii-utils` 5-class scanner a second time (audit-log already redacts on
29
+ * write — this is belt-and-braces per the plan's hard rule, `piiRedact`).
30
+ *
31
+ * @module dsh-edge-agent/edge-context-view
32
+ */
33
+ import type { Context } from '@deepseek-ai/cordis';
34
+ import { Service } from '@deepseek-ai/cordis';
35
+ import Schema from '@deepseek-ai/schemastery';
36
+ import type { LlmTraceStoreService } from './llm-trace-store.js';
37
+ import type { AuditEntry } from './audit-log.js';
38
+ export declare const name = "edge-context-view";
39
+ /** Hard dep: the fold is fed exclusively through the audit stream.
40
+ * `connection` arms later via deferred ctx.inject (headless profiles
41
+ * never compose it and the plugin must still mount cleanly). */
42
+ export declare const inject: readonly ["auditLog"];
43
+ export interface Config {
44
+ /** Per-panel row cap (ring). Older rows are dropped from the front. */
45
+ bufferSize: number;
46
+ /** Idle sessions are swept from the store after this long. */
47
+ sessionTtlMs: number;
48
+ /** Re-scan every stored payload string against the 5 PII classes. */
49
+ piiRedact: boolean;
50
+ }
51
+ export declare const Config: Schema<Config>;
52
+ /** Panel ids the client mounts. */
53
+ export type EdgePanel = 'intent' | 'route' | 'interception' | 'threat';
54
+ export interface EdgeRow {
55
+ ts: number;
56
+ kind: string;
57
+ panel: EdgePanel;
58
+ /** Audit entry level, carried so the client can tint warn/error rows. */
59
+ level: string;
60
+ payload: Record<string, unknown>;
61
+ /** Service-global monotonic sequence — the cursor is comparable across
62
+ * the session bucket and the global bucket, which is what a pull
63
+ * merges. (A per-bucket counter would let either side rewind it.) */
64
+ seq: number;
65
+ /** §7.106: committed-turn count at record time; -1 = global-bucket row
66
+ * (session-independent: boot probes, rate-limit signals — always
67
+ * rendered). The client keeps session rows whose turn equals the
68
+ * session's current turn — each new turn wipes the panel back to fresh
69
+ * records (user ruling 2026-10-22: 新的对话旧记录删除). */
70
+ turn: number;
71
+ }
72
+ export interface EdgeSessionSummary {
73
+ sessionId: string;
74
+ startedAt?: number;
75
+ lastSeenAt?: number;
76
+ /** Committed turn count (from `turn/start` session events). Doubles as the
77
+ * current-turn tag the client filters rows by (see {@link EdgeRow.turn}). */
78
+ turns: number;
79
+ /** §7.106: wall clock of the newest `turn/start`. Global-bucket rows (turn
80
+ * -1 — host answer steps carry no sessionId into adapter audits) have no
81
+ * turn attribution, so the client renders them only when ts >= this:
82
+ * last-turn rows age out with the window. */
83
+ turnStartTs?: number;
84
+ /** Token accounting from `assistant/message.usage`; undefined until an
85
+ * adapter reported usage (never faked as 0). */
86
+ tokens?: {
87
+ input?: number;
88
+ output?: number;
89
+ cacheRead?: number;
90
+ };
91
+ /**
92
+ * §7.106 (2026-10-23): the last answer route observed for this session.
93
+ * `request/context` only fires when the route CHANGES (host contract),
94
+ * so a window filtered to the current turn would show "route undecided"
95
+ * for every turn that kept the same model — even after 3.6k tokens had
96
+ * already streamed back. The route outlives the window; the answer node
97
+ * reads this when no in-window row exists.
98
+ */
99
+ lastRoute?: {
100
+ provider: string;
101
+ model: string;
102
+ ts: number;
103
+ };
104
+ }
105
+ export interface EdgeContextSnapshot {
106
+ /** Cursor to pass back on the next pull: rows strictly newer than this. */
107
+ cursor: number;
108
+ rows: EdgeRow[];
109
+ summary: EdgeSessionSummary;
110
+ }
111
+ export interface EdgeContextViewService {
112
+ snapshot(sessionId: string, cursor?: number): EdgeContextSnapshot;
113
+ /** Test/inspection helper. */
114
+ trackedSessionIds(): string[];
115
+ /**
116
+ * §7.133: LLM trace 正文存储 —— 索引行在 audit 里,正文在这里。
117
+ * 面板点开一次调用时按 traceId 取。
118
+ */
119
+ readonly traceStore: LlmTraceStoreService;
120
+ }
121
+ /** Panel resolution with the threat re-route winning over the generic
122
+ * interception prefix. */
123
+ export declare function panelForKind(kind: string): EdgePanel | undefined;
124
+ /** Best-effort session attribution. Most guard/intent audit sites do not
125
+ * carry a sessionId (they run pre-turn); those rows land in `_global`
126
+ * and are merged into every snapshot — same fallback semantics as the
127
+ * §7.43 realm forensics. Rows with a real sessionId stay session-scoped. */
128
+ export declare const GLOBAL_BUCKET = "_global";
129
+ /** Copy the payload with only scalar-safe fields, re-tokenized. Nested
130
+ * objects/arrays are flattened away EXCEPT small primitives we want
131
+ * (numbers/booleans kept; strings run through the PII scanner). */
132
+ export declare function safePayload(payload: Record<string, unknown>, piiRedact: boolean): Record<string, unknown>;
133
+ export declare class EdgeContextViewServiceImpl extends Service implements EdgeContextViewService {
134
+ private readonly cfg;
135
+ private readonly buckets;
136
+ private readonly summaries;
137
+ /** §7.133: LLM trace 正文存储(内存 ring)。 */
138
+ readonly traceStore: LlmTraceStoreService;
139
+ /** Service-global row sequence (the pull cursor). */
140
+ private rowSeq;
141
+ private sweepTimer;
142
+ constructor(ctx: Context, config: Config);
143
+ /** Wire the live fold. Returns a disposer. */
144
+ start(): () => void;
145
+ /** Fold one audit record into the right panel bucket. Exported for tests
146
+ * through the service; kept public because the session-event listener
147
+ * also synthesizes rows through it. */
148
+ record(entry: AuditEntry): void;
149
+ /** session/event fold (turn count + host answer rows + usage). `usage` is
150
+ * read defensively: adapters report it only when the upstream answered
151
+ * with accounting. §7.106: field names follow dsh-llm's `TokenUsage`
152
+ * (`inputTokens/outputTokens/cacheReadTokens`); the earlier code read
153
+ * `input/output`, which made every real host usage map to nothing while
154
+ * an empty `tokens` object still painted "0 in / 0 out" — tokens is now
155
+ * only created when at least one authoritative number arrived. */
156
+ recordSessionEvent(sessionId: string, type: string, data: unknown): void;
157
+ /** Synthesize a Route-panel row from a session-event observation. Same ring
158
+ * and cursor as audit-fed rows; level `info`, PII-free scalars only. */
159
+ private hostRow;
160
+ snapshot(sessionId: string, cursor?: number): EdgeContextSnapshot;
161
+ trackedSessionIds(): string[];
162
+ private bucketFor;
163
+ private summaryFor;
164
+ private sweep;
165
+ }
166
+ declare module '@deepseek-ai/cordis' {
167
+ interface Context {
168
+ edgeContextView: EdgeContextViewServiceImpl;
169
+ }
170
+ }
171
+ /** The client-side pull route (browser-relative spelling lives in the
172
+ * client bundle; the host registers the absolute path behind the
173
+ * harness `/api` fence — same discipline as dsh-context apiRoutes). */
174
+ export declare const EDGE_CONTEXT_ROUTE = "/api/dsh-edge/context";
175
+ /**
176
+ * §7.133: 取 LLM trace 正文的路由。
177
+ *
178
+ * 面板点开一次调用 → POST `{ traceId }` → 返回那条 trace 的完整正文。
179
+ * 索引行在 audit 里(几十字节),正文在这里按需取 —— 面板常驻内存不
180
+ * 受 22.5 万字符的 request 正文影响。
181
+ */
182
+ export declare const EDGE_TRACE_ROUTE = "/api/dsh-edge/trace";
183
+ export declare function apply(ctx: Context, config: Config): Promise<() => void>;
184
+ //# sourceMappingURL=edge-context-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-context-view.d.ts","sourceRoot":"","sources":["../../src/dsh-adapter/edge-context-view.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,483 @@
1
+ /**
2
+ * edge-context-view — §7.101 host half of the desktop "Edge" tab.
3
+ *
4
+ * Folds the edge privacy audit stream into four panel row-lists (intent /
5
+ * route / interception / threat) plus a per-session summary (turns + tokens
6
+ * + lifetime), and serves them to the desktop client over a framework-
7
+ * standard Connection POST route (`/api/dsh-edge/context`).
8
+ *
9
+ * Data-path decision (docs/notes/dsh-context-walkthrough.md, rulings A/B/D):
10
+ * - The panel sources are audit-log records — NOT subscribable plugin
11
+ * event streams. Live folding rides the new `auditLog.onEntry` seam;
12
+ * `query()` re-reads the whole file and is unusable as a poll source.
13
+ * - SessionEvent vocabulary is closed (no custom kinds), so
14
+ * `sessionProjections` cannot carry edge privacy events. The transport
15
+ * is the Connection fetch-route (dsh-context's detail-channel pattern);
16
+ * the client pulls with a cursor while the tab is mounted.
17
+ * - `ctx.edgeContextView.snapshot()` is a plain data fold, not an
18
+ * AsyncIterable: the browser cannot subscribe to cordis services, so a
19
+ * host-side stream would be an untestable dead API. Unit tests hit the
20
+ * fold directly.
21
+ * - Turns/tokens ride the in-process `session/event` listener (same
22
+ * idiom as http-api.ts:567): turn count from `turn/start`, tokens from
23
+ * `assistant/message` `usage` when the adapter reported accounting —
24
+ * absent usage, the summary says so instead of promising numbers.
25
+ *
26
+ * PII discipline: rows are counts/categories/timestamps/levels only at the
27
+ * panel level; before a payload is stored, every string value passes the
28
+ * `pii-utils` 5-class scanner a second time (audit-log already redacts on
29
+ * write — this is belt-and-braces per the plan's hard rule, `piiRedact`).
30
+ *
31
+ * @module dsh-edge-agent/edge-context-view
32
+ */
33
+ import { Service } from '@deepseek-ai/cordis';
34
+ import Schema from '@deepseek-ai/schemastery';
35
+ import { redactObject } from './pii-utils.js';
36
+ import { provideTraceStore } from './llm-trace-store.js';
37
+ import { realSessionId } from './session-attribution.js';
38
+ export const name = 'edge-context-view';
39
+ /** Hard dep: the fold is fed exclusively through the audit stream.
40
+ * `connection` arms later via deferred ctx.inject (headless profiles
41
+ * never compose it and the plugin must still mount cleanly). */
42
+ export const inject = ['auditLog'];
43
+ export const Config = Schema.object({
44
+ bufferSize: Schema.natural().default(64),
45
+ sessionTtlMs: Schema.natural().default(1_800_000),
46
+ piiRedact: Schema.boolean().default(true),
47
+ });
48
+ /** Kinds that should never reach a panel — boot-time plumbing, hook
49
+ * registration, dependency probes. They are useful in audit but
50
+ * useless (and confusing) in a live monitor. */
51
+ const INTERNAL_KINDS = new Set([
52
+ 'input-guard.hook.registered',
53
+ 'input-guard.dependency.missing',
54
+ 'input-guard.boot.probe',
55
+ 'input-guard.session-realm.registered',
56
+ ]);
57
+ /** Kind → panel routing table, dumped from the live audit call sites
58
+ * (see walkthrough §6). Unlisted kinds are ignored — the store only
59
+ * carries what the panels render. */
60
+ const PANEL_BY_KIND = [
61
+ // §7.175 (2026-11-11): `input-guard.intent.downgraded` 退役(路由改写
62
+ // 机制整体撤掉),取代它的是 `input-guard.intent.local` —— 本轮判定为
63
+ // LOCAL 时记下的**实际用的** provider/model。它是意图裁决的一环 ⇒ 进
64
+ // intent 面板(此前 `intent.downgraded` 也在这一档)。
65
+ { match: k => k.startsWith('intent.') || k === 'input-guard.intent.local', panel: 'intent' },
66
+ {
67
+ match: k => k === 'http.chat.route'
68
+ || k === 'http.chat.stream.route'
69
+ || k.startsWith('llm.cloud.')
70
+ || k.startsWith('llm.local.')
71
+ // §7.106: host answer routes folded from the session event stream —
72
+ // the desktop GUI answers through the host's own model (never an
73
+ // edge adapter), so without these the Route panel cannot show where
74
+ // the reply actually came from.
75
+ || k.startsWith('llm.host.')
76
+ // §7.133: LLM 调用的**索引行** —— 面板列出「这一轮调了哪几次 LLM、
77
+ // 各自多大、多久」。正文不在行里(单轮 22.5 万字符),点开时按
78
+ // traceId 去 store 取。
79
+ || k === 'llm.trace.index',
80
+ panel: 'route',
81
+ },
82
+ {
83
+ // Interception = everything the guards/sanitizers decided or rewrote.
84
+ // intent.*/http.chat.route already matched above, so order matters.
85
+ match: k => (!INTERNAL_KINDS.has(k) && k.startsWith('input-guard.'))
86
+ || k.startsWith('guard.')
87
+ || k.startsWith('sanitizer.')
88
+ || k.startsWith('input.'),
89
+ panel: 'interception',
90
+ },
91
+ {
92
+ // Threat: escalations + rate-limit trips + explicit threat blocks.
93
+ // (`input.rate-limit`/`input.global-rate-limit` hit the interception
94
+ // rule first by prefix; re-route them here — they are td signals.)
95
+ match: k => k.startsWith('td-bus.')
96
+ || k === 'http.chat.threat-blocked'
97
+ || k === 'input.rate-limit'
98
+ || k === 'input.global-rate-limit',
99
+ panel: 'threat',
100
+ },
101
+ ];
102
+ /** Panel resolution with the threat re-route winning over the generic
103
+ * interception prefix. */
104
+ export function panelForKind(kind) {
105
+ if (kind === 'input.rate-limit' || kind === 'input.global-rate-limit')
106
+ return 'threat';
107
+ for (const rule of PANEL_BY_KIND) {
108
+ if (rule.match(kind))
109
+ return rule.panel;
110
+ }
111
+ return undefined;
112
+ }
113
+ /** Best-effort session attribution. Most guard/intent audit sites do not
114
+ * carry a sessionId (they run pre-turn); those rows land in `_global`
115
+ * and are merged into every snapshot — same fallback semantics as the
116
+ * §7.43 realm forensics. Rows with a real sessionId stay session-scoped. */
117
+ export const GLOBAL_BUCKET = '_global';
118
+ function extractSessionId(payload) {
119
+ // §7.147: 哨兵 `'<pre-session>'`(ops log 的「无会话」显示约定,§7.52)
120
+ // **不是**会话 id。它若被当成 id,会造出一个不存在的桶 —— 那几行从此只在
121
+ // 「有人请求那个假 id」时可见,等于从所有真实会话的视图里消失(比归全局
122
+ // 更糟:宁可全局可见,不要无处可见)。所以这里也判成「没有会话」。
123
+ const sid = realSessionId(payload.sessionId ?? payload.session);
124
+ return sid ?? GLOBAL_BUCKET;
125
+ }
126
+ /** §7.121: how many elements of a string array survive into the panel.
127
+ * Rule-name lists are short (injection rules ≤ a few dozen); the cap is
128
+ * a payload-size guard, not a semantic one. */
129
+ const ARRAY_CAP = 32;
130
+ /** Copy the payload with only scalar-safe fields, re-tokenized. Nested
131
+ * objects/arrays are flattened away EXCEPT small primitives we want
132
+ * (numbers/booleans kept; strings run through the PII scanner). */
133
+ export function safePayload(payload, piiRedact) {
134
+ const out = {};
135
+ for (const [key, value] of Object.entries(payload)) {
136
+ if (typeof value === 'string') {
137
+ if (!piiRedact) {
138
+ out[key] = value;
139
+ continue;
140
+ }
141
+ const { redacted } = redactObject({ [key]: value });
142
+ const v = redacted[key];
143
+ out[key] = typeof v === 'string' ? v : value;
144
+ }
145
+ else if (typeof value === 'number' || typeof value === 'boolean') {
146
+ out[key] = value;
147
+ }
148
+ else if (value === null) {
149
+ out[key] = null;
150
+ }
151
+ else if (typeof value === 'object' && !Array.isArray(value)) {
152
+ // §7.106 (2026-10-23): ONE level of scalar-only nested objects is
153
+ // flattened (`from`/`to` of route.overridden carry exactly the rewrite
154
+ // evidence the flow view shows — dropping them rendered "?/? → ?/?").
155
+ // Deeper objects and arrays stay dropped: panels render scalar rows;
156
+ // deep shapes are the detail channel the plan explicitly cut.
157
+ for (const [k, v] of Object.entries(value)) {
158
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
159
+ out[`${key}.${k}`] = v;
160
+ }
161
+ }
162
+ }
163
+ else if (Array.isArray(value)) {
164
+ // §7.121: string arrays carry the **原始命中明细** and were dropped
165
+ // wholesale — which silently erased the very evidence the operator
166
+ // needs ("哪条规则命中了"). `input.injection.warned` carries
167
+ // `patterns: ['role-override-zh']`; `guard.model-detect.unsafe`
168
+ // carries `threats: [...]`; `sanitizer.sanitized` carries the matched
169
+ // PII types. All of them are **rule/type identifiers** — never user
170
+ // content — so they are safe to surface verbatim.
171
+ //
172
+ // Guardrails: only string elements are kept (numbers/objects are
173
+ // dropped, an object element could carry anything); the count is
174
+ // capped; each element is still PII-scanned so a rule name that
175
+ // somehow embeds a phone number cannot leak.
176
+ const parts = [];
177
+ for (const item of value) {
178
+ if (typeof item !== 'string')
179
+ continue;
180
+ parts.push(piiRedact ? redactObject({ [key]: item }).redacted[key] : item);
181
+ if (parts.length >= ARRAY_CAP)
182
+ break;
183
+ }
184
+ if (parts.length > 0)
185
+ out[key] = parts;
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ export class EdgeContextViewServiceImpl extends Service {
191
+ cfg;
192
+ buckets = new Map();
193
+ summaries = new Map();
194
+ /** §7.133: LLM trace 正文存储(内存 ring)。 */
195
+ traceStore;
196
+ /** Service-global row sequence (the pull cursor). */
197
+ rowSeq = 0;
198
+ sweepTimer;
199
+ constructor(ctx, config) {
200
+ super(ctx, 'edgeContextView');
201
+ this.cfg = config;
202
+ // §7.133: 取**共享**的 trace store(写方是 adapter / 安检 / 分类器,
203
+ // 它们先注册;这里取同一个实例 —— 模块单例保证读写同库)。
204
+ this.traceStore = provideTraceStore(ctx);
205
+ }
206
+ /** Wire the live fold. Returns a disposer. */
207
+ start() {
208
+ const auditLog = this.ctx.auditLog;
209
+ const off = auditLog?.onEntry?.(entry => this.record(entry)) ?? (() => { });
210
+ this.sweepTimer = setInterval(() => this.sweep(), Math.max(60_000, Math.floor(this.cfg.sessionTtlMs / 2)));
211
+ if (typeof this.sweepTimer.unref === 'function')
212
+ this.sweepTimer.unref();
213
+ return () => {
214
+ off();
215
+ if (this.sweepTimer !== undefined)
216
+ clearInterval(this.sweepTimer);
217
+ this.sweepTimer = undefined;
218
+ };
219
+ }
220
+ /** Fold one audit record into the right panel bucket. Exported for tests
221
+ * through the service; kept public because the session-event listener
222
+ * also synthesizes rows through it. */
223
+ record(entry) {
224
+ const panel = panelForKind(entry.kind);
225
+ if (panel === undefined)
226
+ return;
227
+ const sessionId = extractSessionId(entry.payload);
228
+ const bucket = this.bucketFor(sessionId);
229
+ this.rowSeq += 1;
230
+ const row = {
231
+ ts: entry.ts,
232
+ kind: entry.kind,
233
+ panel,
234
+ level: entry.level,
235
+ seq: this.rowSeq,
236
+ payload: safePayload(entry.payload, this.cfg.piiRedact),
237
+ // Read-only turn tag: rows fold during a turn (pre-step runs after
238
+ // turn/start commits), so the count already reflects this turn.
239
+ // Global-bucket rows (no session — boot probes, rate-limit signals)
240
+ // carry the -1 sentinel: they are session-independent and always
241
+ // render (filtering them would silence the Threat panel forever).
242
+ turn: sessionId === GLOBAL_BUCKET ? -1 : (this.summaries.get(sessionId)?.turns ?? 0),
243
+ };
244
+ bucket.rows.push(row);
245
+ if (bucket.rows.length > this.cfg.bufferSize)
246
+ bucket.rows.splice(0, bucket.rows.length - this.cfg.bufferSize);
247
+ bucket.lastTouched = Date.now();
248
+ }
249
+ /** session/event fold (turn count + host answer rows + usage). `usage` is
250
+ * read defensively: adapters report it only when the upstream answered
251
+ * with accounting. §7.106: field names follow dsh-llm's `TokenUsage`
252
+ * (`inputTokens/outputTokens/cacheReadTokens`); the earlier code read
253
+ * `input/output`, which made every real host usage map to nothing while
254
+ * an empty `tokens` object still painted "0 in / 0 out" — tokens is now
255
+ * only created when at least one authoritative number arrived. */
256
+ recordSessionEvent(sessionId, type, data) {
257
+ const summary = this.summaryFor(sessionId);
258
+ const now = Date.now();
259
+ summary.lastSeenAt = now;
260
+ if (summary.startedAt === undefined)
261
+ summary.startedAt = now;
262
+ if (type === 'turn/start') {
263
+ summary.turns += 1;
264
+ summary.turnStartTs = now;
265
+ }
266
+ else if (type === 'assistant/message') {
267
+ const usage = data?.usage;
268
+ if (usage !== null && typeof usage === 'object') {
269
+ const acc = (key, legacyKey) => {
270
+ const v = usage[key] ?? usage[legacyKey];
271
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
272
+ };
273
+ const input = acc('inputTokens', 'input');
274
+ const output = acc('outputTokens', 'output');
275
+ const cacheRead = acc('cacheReadTokens', 'cacheRead');
276
+ if (input !== undefined || output !== undefined || cacheRead !== undefined) {
277
+ summary.tokens = summary.tokens ?? {};
278
+ if (input !== undefined)
279
+ summary.tokens.input = (summary.tokens.input ?? 0) + input;
280
+ if (output !== undefined)
281
+ summary.tokens.output = (summary.tokens.output ?? 0) + output;
282
+ if (cacheRead !== undefined)
283
+ summary.tokens.cacheRead = (summary.tokens.cacheRead ?? 0) + cacheRead;
284
+ }
285
+ }
286
+ }
287
+ else if (type === 'request/context') {
288
+ // The host resolved its answer route (dsh records this event when the
289
+ // provider/model changes — one row per distinct route, not per turn).
290
+ const ctxData = (data ?? {});
291
+ if (typeof ctxData.provider === 'string' || typeof ctxData.model === 'string') {
292
+ const provider = typeof ctxData.provider === 'string' ? ctxData.provider : '';
293
+ const model = typeof ctxData.model === 'string' ? ctxData.model : '';
294
+ this.hostRow(sessionId, 'llm.host.route', { provider, model });
295
+ // Route outlives the turn window — the client answer node reads it
296
+ // when the current turn produced no new request/context event.
297
+ if (provider.length > 0)
298
+ summary.lastRoute = { provider, model, ts: now };
299
+ }
300
+ }
301
+ }
302
+ /** Synthesize a Route-panel row from a session-event observation. Same ring
303
+ * and cursor as audit-fed rows; level `info`, PII-free scalars only. */
304
+ hostRow(sessionId, kind, fields) {
305
+ this.rowSeq += 1;
306
+ const bucket = this.bucketFor(sessionId);
307
+ bucket.rows.push({
308
+ ts: Date.now(),
309
+ kind,
310
+ panel: 'route',
311
+ level: 'info',
312
+ seq: this.rowSeq,
313
+ payload: { ...fields },
314
+ turn: this.summaries.get(sessionId)?.turns ?? 0,
315
+ });
316
+ if (bucket.rows.length > this.cfg.bufferSize)
317
+ bucket.rows.splice(0, bucket.rows.length - this.cfg.bufferSize);
318
+ bucket.lastTouched = Date.now();
319
+ }
320
+ snapshot(sessionId, cursor = 0) {
321
+ const rows = [];
322
+ // §7.133: the global bucket must not be visited twice. Asking for the
323
+ // `_global` snapshot used to push every global row **twice** (the same
324
+ // bucket object came back from both lookups) — the panel then rendered
325
+ // each event twice. It became visible with the trace rows (§7.133): one
326
+ // LLM call showed as two identical entries.
327
+ const buckets = sessionId === GLOBAL_BUCKET
328
+ ? [this.buckets.get(GLOBAL_BUCKET)]
329
+ : [this.buckets.get(sessionId), this.buckets.get(GLOBAL_BUCKET)];
330
+ for (const bucket of buckets) {
331
+ if (!bucket)
332
+ continue;
333
+ for (const row of bucket.rows) {
334
+ if (row.seq > cursor)
335
+ rows.push(row);
336
+ }
337
+ }
338
+ rows.sort((a, b) => a.seq - b.seq);
339
+ const nextCursor = rows.reduce((m, r) => Math.max(m, r.seq), cursor);
340
+ const summary = this.summaryFor(sessionId);
341
+ return { cursor: nextCursor, rows, summary: { ...summary } };
342
+ }
343
+ trackedSessionIds() {
344
+ return [...this.buckets.keys()].filter(k => k !== GLOBAL_BUCKET);
345
+ }
346
+ bucketFor(sessionId) {
347
+ let bucket = this.buckets.get(sessionId);
348
+ if (!bucket) {
349
+ bucket = { rows: [], lastTouched: Date.now() };
350
+ this.buckets.set(sessionId, bucket);
351
+ }
352
+ return bucket;
353
+ }
354
+ summaryFor(sessionId) {
355
+ let summary = this.summaries.get(sessionId);
356
+ if (!summary) {
357
+ summary = { sessionId, turns: 0 };
358
+ this.summaries.set(sessionId, summary);
359
+ }
360
+ return summary;
361
+ }
362
+ sweep() {
363
+ const now = Date.now();
364
+ for (const [id, bucket] of this.buckets) {
365
+ if (id !== GLOBAL_BUCKET && now - bucket.lastTouched > this.cfg.sessionTtlMs) {
366
+ this.buckets.delete(id);
367
+ this.summaries.delete(id);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ /** The client-side pull route (browser-relative spelling lives in the
373
+ * client bundle; the host registers the absolute path behind the
374
+ * harness `/api` fence — same discipline as dsh-context apiRoutes). */
375
+ export const EDGE_CONTEXT_ROUTE = '/api/dsh-edge/context';
376
+ /**
377
+ * §7.133: 取 LLM trace 正文的路由。
378
+ *
379
+ * 面板点开一次调用 → POST `{ traceId }` → 返回那条 trace 的完整正文。
380
+ * 索引行在 audit 里(几十字节),正文在这里按需取 —— 面板常驻内存不
381
+ * 受 22.5 万字符的 request 正文影响。
382
+ */
383
+ export const EDGE_TRACE_ROUTE = '/api/dsh-edge/trace';
384
+ export async function apply(ctx, config) {
385
+ // Boot evidence for the desktop ops log (file-logger picks this up via
386
+ // the ctx.logger exporter chain). Proves the patch row activated.
387
+ ctx.logger?.info?.('[edge-context-view] apply() called — Edge tab host half mounting');
388
+ const service = new EdgeContextViewServiceImpl(ctx, config);
389
+ // No `ctx.edgeContextView = ...` assignment: cordis 0.2.1-rc.2 plugin
390
+ // proxy ctx can't accept arbitrary property writes (would throw
391
+ // `cannot set property "x" without provide`). Service is captured by
392
+ // closure in the route handler below.
393
+ const disposeFold = service.start();
394
+ // Deferred arms: `connection` only exists on GUI hosts. Headless profiles
395
+ // (dsh-edge/dsh-tui) never compose it → no route, plugin stays clean.
396
+ ctx.inject(['connection'], (raw) => {
397
+ const c = raw;
398
+ const register = c.connection?.fetch?.register;
399
+ if (typeof register !== 'function')
400
+ return;
401
+ try {
402
+ register.call(c.connection.fetch, {
403
+ path: EDGE_CONTEXT_ROUTE,
404
+ methods: ['POST'],
405
+ requestBody: 'buffered',
406
+ fetch: async (request) => {
407
+ // Re-prove every input at the boundary — the wire is untrusted.
408
+ let body;
409
+ try {
410
+ body = await request.json();
411
+ }
412
+ catch {
413
+ return new Response('{"error":"invalid-json"}', { status: 400, headers: { 'content-type': 'application/json' } });
414
+ }
415
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0 ? body.sessionId : GLOBAL_BUCKET;
416
+ const cursor = typeof body.cursor === 'number' && Number.isFinite(body.cursor) && body.cursor >= 0
417
+ ? Math.floor(body.cursor) : 0;
418
+ const snapshot = service.snapshot(sessionId, cursor);
419
+ // Host rows are already PII-scanned at store time; keep the audit
420
+ // trail of who is pulling out of the payload (no new kinds).
421
+ return new Response(JSON.stringify(snapshot), { status: 200, headers: { 'content-type': 'application/json' } });
422
+ },
423
+ });
424
+ }
425
+ catch { /* a rejecting registry only costs the route, not the plugin */ }
426
+ // §7.133: trace 正文路由 —— 面板点开一次调用时按 traceId 取。
427
+ try {
428
+ register.call(c.connection.fetch, {
429
+ path: EDGE_TRACE_ROUTE,
430
+ methods: ['POST'],
431
+ requestBody: 'buffered',
432
+ fetch: async (request) => {
433
+ let body;
434
+ try {
435
+ body = await request.json();
436
+ }
437
+ catch {
438
+ return new Response('{"error":"invalid-json"}', { status: 400, headers: { 'content-type': 'application/json' } });
439
+ }
440
+ const traceId = typeof body.traceId === 'string' ? body.traceId : '';
441
+ if (traceId.length === 0) {
442
+ return new Response('{"error":"missing-trace-id"}', { status: 400, headers: { 'content-type': 'application/json' } });
443
+ }
444
+ const found = service.traceStore.get(traceId);
445
+ if (found === undefined) {
446
+ // 正文已被 ring 淘汰 / id 不存在 —— 诚实地说「取不到」,不返回空对象
447
+ // 冒充「正文是空的」。
448
+ return new Response('{"error":"trace-not-found"}', { status: 404, headers: { 'content-type': 'application/json' } });
449
+ }
450
+ return new Response(JSON.stringify(found), { status: 200, headers: { 'content-type': 'application/json' } });
451
+ },
452
+ });
453
+ }
454
+ catch { /* a rejecting registry only costs the route, not the plugin */ }
455
+ });
456
+ // In-process session/event fold (turns + tokens). Cordis 0.2.1-rc.2's
457
+ // plugin-level proxy ctx does NOT expose `.on` directly (would throw
458
+ // `cannot get property "on" without inject`); only Service-internal ctx
459
+ // has it. Wrap in a try/catch so a missing fiber-root `.on` only costs
460
+ // the session summary counter, not the whole plugin mount.
461
+ let disposeSession;
462
+ try {
463
+ const ctxOn = ctx.on;
464
+ if (typeof ctxOn === 'function') {
465
+ disposeSession = ctxOn.call(ctx, 'session/event', (session, event) => {
466
+ const sid = typeof session?.id === 'string' && session.id.length > 0 ? session.id : undefined;
467
+ if (sid === undefined)
468
+ return;
469
+ if (event?.type !== 'turn/start' && event?.type !== 'assistant/message' && event?.type !== 'request/context')
470
+ return;
471
+ service.recordSessionEvent(sid, event.type, event.data);
472
+ });
473
+ }
474
+ }
475
+ catch {
476
+ // Plugin-level proxy ctx can't subscribe events. Session summary
477
+ // will fall back to audit-log derived data only.
478
+ }
479
+ return () => {
480
+ disposeFold();
481
+ disposeSession?.();
482
+ };
483
+ }