dsh-context 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,7 +1,247 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, normalize } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import { z } from "zod";
2
5
  import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
6
  import z$1 from "@deepseek-ai/schemastery";
4
7
  import { deriveEventMessage } from "@deepseek-ai/dsh-session";
8
+ /**
9
+ * Recover the MCP server display label from a proxied tool name, or undefined
10
+ * for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
11
+ * (normalized, and hash-appended when overlong/invalid — the label then shows
12
+ * whatever of the server survived the truncation). The separating `__` is the
13
+ * LAST one in the name, and it must sit AFTER the `mcp__` prefix: the prefix's
14
+ * own separator (or an empty server right after it) is not a server.
15
+ */
16
+ function mcpServerOf(name) {
17
+ if (!name.startsWith("mcp__")) return void 0;
18
+ const cut = name.lastIndexOf("__");
19
+ if (cut < 5) return void 0;
20
+ const server = name.slice(5, cut);
21
+ return server.length > 0 ? server : void 0;
22
+ }
23
+ /** The `mcp:<server>` display label of a proxied tool name, or undefined. */
24
+ function mcpSourceOf(name) {
25
+ const server = mcpServerOf(name);
26
+ return server !== void 0 ? `mcp:${server}` : void 0;
27
+ }
28
+ /** The pinned package of a first-party tool name, or undefined. */
29
+ function pinnedSourceOf(name) {
30
+ return FIRST_PARTY_SOURCES[name];
31
+ }
32
+ /**
33
+ * Pinned first-party tool → plugin package map (see header comment). One entry
34
+ * per model-facing name of the shipped tool packages at dsh 0.1.1-rc.2, from
35
+ * the official tool-schema catalog; names are stable across the supported
36
+ * harness range. Packages that mount under distinct names per composition
37
+ * (bash/pwsh persistent variants, subagent fork) map to their primary package.
38
+ */
39
+ const FIRST_PARTY_SOURCES = Object.freeze({
40
+ read: "@deepseek-ai/dsh-tool-fs",
41
+ write: "@deepseek-ai/dsh-tool-fs",
42
+ edit: "@deepseek-ai/dsh-tool-fs",
43
+ read_image: "@deepseek-ai/dsh-tool-fs",
44
+ glob: "@deepseek-ai/dsh-tool-fs-search",
45
+ grep: "@deepseek-ai/dsh-tool-fs-search",
46
+ str_replace_editor: "@deepseek-ai/dsh-tool-str-replace-editor",
47
+ bash: "@deepseek-ai/dsh-tool-bash",
48
+ pwsh: "@deepseek-ai/dsh-tool-pwsh",
49
+ web_search: "@deepseek-ai/dsh-tool-web",
50
+ web_fetch: "@deepseek-ai/dsh-tool-web",
51
+ job_output: "@deepseek-ai/dsh-tool-jobs",
52
+ job_list: "@deepseek-ai/dsh-tool-jobs",
53
+ job_kill: "@deepseek-ai/dsh-tool-jobs",
54
+ ask_user_question: "@deepseek-ai/dsh-tool-ask-user",
55
+ plan: "@deepseek-ai/dsh-plan-mode",
56
+ exit_plan_mode: "@deepseek-ai/dsh-plan-mode",
57
+ skill: "@deepseek-ai/dsh-tool-skill",
58
+ todo_write: "@deepseek-ai/dsh-tool-todo",
59
+ subagent: "@deepseek-ai/dsh-tool-subagent",
60
+ subagent_fork: "@deepseek-ai/dsh-tool-subagent",
61
+ send_message: "@deepseek-ai/dsh-tool-subagent-control",
62
+ interrupt_agent: "@deepseek-ai/dsh-tool-subagent-control",
63
+ list_agents: "@deepseek-ai/dsh-tool-subagent-control",
64
+ ralph: "@deepseek-ai/dsh-tool-ralph",
65
+ workflow: "@deepseek-ai/dsh-tool-workflow",
66
+ run_code: "@deepseek-ai/dsh-tools",
67
+ schedule_create: "@deepseek-ai/dsh-schedule",
68
+ schedule_list: "@deepseek-ai/dsh-schedule",
69
+ schedule_delete: "@deepseek-ai/dsh-schedule",
70
+ create_goal: "@deepseek-ai/dsh-tool-goal",
71
+ get_goal: "@deepseek-ai/dsh-tool-goal",
72
+ update_goal: "@deepseek-ai/dsh-tool-goal",
73
+ lsp: "@deepseek-ai/dsh-tool-lsp"
74
+ });
75
+ //#endregion
76
+ //#region src/host/attribution.ts
77
+ /**
78
+ * Live tool→plugin attribution layered on the static recovery in
79
+ * toolSources.ts.
80
+ *
81
+ * The session log records tools as plain `ToolSchema` entries (name /
82
+ * description / parameters) — the registering plugin is not in there.
83
+ * toolSources.ts derives the deterministic sources (harness-logged field,
84
+ * `mcp:<server>` naming, pinned first-party map). This module additionally
85
+ * watches RUNTIME registrations: cordis fires the `internal/get` waterfall on
86
+ * every context read of a service property, passing the READING context as the
87
+ * first argument, so `reader.fiber.name` identifies the plugin that is about
88
+ * to call `register()`.
89
+ *
90
+ * - The `internal/get` handler records who last read the `tools` service and
91
+ * wraps that instance's `register` (once — an earlier wrapper of a previous
92
+ * hook incarnation is peeled back to the original, so a plugin reload
93
+ * re-wraps without stacking) to capture the reader at registration time
94
+ * into a live map.
95
+ * - When the reader slot is missing, root-named, or this plugin's own (e.g.
96
+ * LOCAL-LINK plugins — dev installs via `dsh plugin add <path>` or
97
+ * npm/pnpm link — whose anonymous entrypoints make cordis fall back to the
98
+ * root name), the wrapped `register` falls back to the call stack: the first
99
+ * frame outside this package is resolved to its nearest `package.json`
100
+ * `name`. That covers both npm installs (`node_modules/<pkg>`) and local
101
+ * links (any directory carrying a package.json), which never pass through
102
+ * node_modules. Frames that resolve back to this package are skipped.
103
+ * - `ownerOf(name)` prefers the name-derived `mcp:<server>` label (it names
104
+ * the actual provider, where the live record would only ever name the MCP
105
+ * proxy client), then the LIVE record — for a post-boot registration it is
106
+ * the truth, even when the name collides with a pinned first-party tool —
107
+ * then the pinned map (the boot-time guess for tools registered before the
108
+ * hook), and finally tags tools that were ALREADY registered when the hook
109
+ * installed (the boot snapshot — third-party bundles, e.g. local links like
110
+ * dsh-file-claim, that applied before dsh-context) with the
111
+ * `UNKNOWN_TOOL_SOURCE` sentinel: their registering plugin is unknowable,
112
+ * and a bare gap would read as "no plugin" instead of "unknown plugin".
113
+ *
114
+ * Best-effort by design: a read separated from `register()` by an `await` can
115
+ * be overwritten by another plugin's read (misattribution) and the stack
116
+ * fallback needs a resolvable package.json — both degrade to the name/pinned
117
+ * chain; registrations that predate the hook degrade to the unknown tag. The
118
+ * hook costs roughly +1.4us per service-property read and is negligible on
119
+ * the rare register path (the stack walk only runs when the reader slot is
120
+ * unusable, and its package lookups are cached per directory).
121
+ */
122
+ /** This module's own file URL — the stack walk skips its own frames. */
123
+ const selfUrl = normalize(fileURLToPath(import.meta.url));
124
+ /** Directory → package-name cache for the synchronous walk below. */
125
+ const packageCache = /* @__PURE__ */ new Map();
126
+ /**
127
+ * Best-effort package name for a module file: walk up to the nearest
128
+ * `package.json` carrying a `name`. Works for dependencies installed under
129
+ * `node_modules` as well as local links whose package root is any on-disk
130
+ * directory. The per-directory results are cached.
131
+ * @param file - absolute path of a module file.
132
+ */
133
+ function packageNameFrom(file) {
134
+ let dir = dirname(file);
135
+ for (let depth = 0; depth < 12; depth++) {
136
+ const cached = packageCache.get(dir);
137
+ if (cached !== void 0) return cached;
138
+ const packageFile = join(dir, "package.json");
139
+ if (existsSync(packageFile)) try {
140
+ const name = JSON.parse(readFileSync(packageFile, "utf8")).name;
141
+ if (typeof name === "string" && name) {
142
+ packageCache.set(dir, name);
143
+ return name;
144
+ }
145
+ } catch {}
146
+ const parent = dirname(dir);
147
+ packageCache.set(dir, void 0);
148
+ if (parent === dir) return void 0;
149
+ dir = parent;
150
+ }
151
+ }
152
+ const FRAME_POSITION = /:\d+:\d+$/;
153
+ /** Package name of this module's own package (self-fallbacks are filtered). */
154
+ const selfPackage = packageNameFrom(selfUrl);
155
+ /**
156
+ * Resolve the registering package from a stack trace: walk frames from the
157
+ * innermost out, skipping this module's own frames and frames that resolve to
158
+ * this package, and return the package name of the first frame that resolves
159
+ * elsewhere. Works with both `file://` URLs and bare absolute paths
160
+ * (transpiled modules render without a scheme), with optional `fn (...)` and
161
+ * `async` wrappers.
162
+ * @param stack - `Error().stack`, or undefined when no fallback is desired.
163
+ */
164
+ function callerPackageFrom(stack) {
165
+ if (!stack) return void 0;
166
+ for (const raw of stack.split("\n").slice(1)) {
167
+ let line = raw.trim();
168
+ if (!line.startsWith("at ")) continue;
169
+ line = line.slice(3);
170
+ if (line.startsWith("async ")) line = line.slice(6);
171
+ line = line.replace(/\)\s*$/, "");
172
+ const position = FRAME_POSITION.exec(line);
173
+ if (!position) continue;
174
+ let target = line.slice(0, -position[0].length);
175
+ if (target.includes("(")) target = target.slice(target.lastIndexOf("(") + 1);
176
+ if (target.startsWith("file://")) try {
177
+ target = normalize(fileURLToPath(target));
178
+ } catch {
179
+ continue;
180
+ }
181
+ else if (!/^[A-Za-z]:[\\/]/.test(target) && !target.startsWith("/") && !target.startsWith("\\\\")) continue;
182
+ else target = normalize(target);
183
+ if (target === selfUrl) continue;
184
+ const name = packageNameFrom(target);
185
+ if (name !== void 0 && name !== selfPackage) return name;
186
+ }
187
+ }
188
+ /**
189
+ * Install the runtime-attribution hook on a cordis app context. The hook
190
+ * rides the calling fiber's lifetime (`ctx.on` / `ctx.get`), so it is
191
+ * disposed with the plugin.
192
+ * @param ctx - the context the dsh-context plugin runs in; its fiber name is
193
+ * excluded from attributions.
194
+ */
195
+ function createToolAttribution(ctx) {
196
+ const live = /* @__PURE__ */ new Map();
197
+ const wrapped = /* @__PURE__ */ new WeakSet();
198
+ const self = ctx.fiber.name;
199
+ let lastReader;
200
+ const wrapInstance = (tools) => {
201
+ if (!tools || typeof tools !== "object" || wrapped.has(tools)) return;
202
+ const register = tools.register;
203
+ if (typeof register !== "function") return;
204
+ wrapped.add(tools);
205
+ const original = register.attributedOriginal ?? register;
206
+ if (typeof original !== "function") return;
207
+ const instance = tools;
208
+ const wrappedRegister = function(definition) {
209
+ const toolName = definition?.name;
210
+ let owner = lastReader?.fiber.name;
211
+ if (!owner || owner === "root" || owner === self) owner = callerPackageFrom((/* @__PURE__ */ new Error()).stack);
212
+ const dispose = original.call(this, definition);
213
+ if (typeof toolName === "string" && owner && owner !== "root" && owner !== self && owner !== selfPackage) {
214
+ live.set(toolName, owner);
215
+ if (typeof dispose === "function") return () => {
216
+ try {
217
+ return dispose();
218
+ } finally {
219
+ live.delete(toolName);
220
+ }
221
+ };
222
+ }
223
+ return dispose;
224
+ };
225
+ wrappedRegister.attributedOriginal = original;
226
+ instance.register = wrappedRegister;
227
+ };
228
+ ctx.on("internal/get", (reader, name, _error, next) => {
229
+ if (name !== "tools") return next();
230
+ const tools = next();
231
+ lastReader = reader;
232
+ wrapInstance(tools);
233
+ return tools;
234
+ });
235
+ const toolsService = ctx.get("tools", false);
236
+ wrapInstance(toolsService);
237
+ const boot = /* @__PURE__ */ new Set();
238
+ try {
239
+ const toolEntries = toolsService?.layers?.global?.tools;
240
+ if (toolEntries !== void 0 && typeof toolEntries.entries === "function") for (const [name] of toolEntries.entries()) boot.add(name);
241
+ } catch {}
242
+ return { ownerOf: (name) => mcpSourceOf(name) ?? live.get(name) ?? pinnedSourceOf(name) ?? (boot.has(name) ? "<unknown-plugin>" : void 0) };
243
+ }
244
+ //#endregion
5
245
  //#region src/host/config.ts
6
246
  /**
7
247
  * dsh-context host configuration — the `config:` block of the `dsh-context`
@@ -322,6 +562,7 @@ const headerToolSchema = z.object({
322
562
  name: z.string(),
323
563
  tokens: z.number().int().nonnegative(),
324
564
  description: z.string().optional(),
565
+ plugin: z.string().optional(),
325
566
  schema: z.unknown().optional()
326
567
  }).strict();
327
568
  const contextHeadersSchema = z.object({ headers: z.array(z.object({
@@ -352,6 +593,7 @@ function recordOf(event) {
352
593
  schema: t
353
594
  };
354
595
  if (typeof tool.description === "string" && tool.description !== "") entry.description = tool.description;
596
+ if (typeof tool.plugin === "string" && tool.plugin !== "") entry.plugin = tool.plugin;
355
597
  return entry;
356
598
  })
357
599
  };
@@ -362,11 +604,20 @@ function recordOf(event) {
362
604
  * The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
363
605
  * `useProjection('contextHeaders')` and degrade to tokens-only header sections when the key is absent. Dual-contract definition (see
364
606
  * compat.ts).
607
+ * @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
608
+ * epochs folded without attribution still render a tag when the source is known.
365
609
  */
366
- function createContextHeadersDefinition() {
610
+ function createContextHeadersDefinition(resolve) {
367
611
  const view = (state) => ({ headers: state.headers.map((h) => ({
368
612
  ...h,
369
- tools: h.tools.map((t) => ({ ...t }))
613
+ tools: h.tools.map((t) => {
614
+ if (resolve === void 0 || t.plugin !== void 0) return { ...t };
615
+ const plugin = resolve(t.name);
616
+ return plugin === void 0 ? { ...t } : {
617
+ ...t,
618
+ plugin
619
+ };
620
+ })
370
621
  })) });
371
622
  return {
372
623
  key: "contextHeaders",
@@ -834,9 +1085,9 @@ function buildTimelineView(state, bounds) {
834
1085
  const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool;
835
1086
  const result = {
836
1087
  ok: true,
837
- model: state.model,
838
- provider: state.provider,
839
- contextWindow: state.contextWindow,
1088
+ ...state.model !== void 0 ? { model: state.model } : {},
1089
+ ...state.provider !== void 0 ? { provider: state.provider } : {},
1090
+ ...state.contextWindow !== void 0 ? { contextWindow: state.contextWindow } : {},
840
1091
  current: {
841
1092
  system: state.systemTokens,
842
1093
  tools: state.toolsTokens,
@@ -1063,6 +1314,9 @@ const timelineStateSchema = z.object({
1063
1314
  * The definition carries BOTH session-projection contracts (see compat.ts):
1064
1315
  * `schema`/`view` for dsh <= 0.1.0-rc.8, `stateSchema`/`wire` for
1065
1316
  * dsh >= 0.1.1-rc.1 — each registry reads its own fields off the same unit.
1317
+ * (The return type is the mirrored dual contract, not the installed dts
1318
+ * `ProjectionDefinition`: the 0.1.1+ registry's wired-register overload
1319
+ * demands `wire` PRESENT, which the dts's optional `wire?` fails.)
1066
1320
  * Without the `wire` block the 0.1.1-rc.1+ registry treats the unit as
1067
1321
  * host-only and never delivers `contextTimeline` to the browser (the Context
1068
1322
  * tab would stay on its loading screen forever).
@@ -1089,8 +1343,9 @@ function createContextTimelineDefinition(config) {
1089
1343
  const name = "dsh-context";
1090
1344
  const inject = ["sessionProjections"];
1091
1345
  function apply(ctx, config) {
1346
+ const attribution = createToolAttribution(ctx);
1092
1347
  ctx.sessionProjections.register(createContextTimelineDefinition(config));
1093
- ctx.sessionProjections.register(createContextHeadersDefinition());
1348
+ ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
1094
1349
  installSettings(ctx);
1095
1350
  }
1096
1351
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "A DeepSeek Harness plugin for context insight and management, with context dashboard and context command, for understanding how the context is made of, and how it evolves.",
5
5
  "author": "bowenliang123",
6
6
  "repository": {
@@ -52,7 +52,6 @@
52
52
  "inject": [
53
53
  "@deepseek-ai/dsh-client-connection",
54
54
  "@deepseek-ai/dsh-client-locale",
55
- "@deepseek-ai/dsh-client-runtime",
56
55
  "@deepseek-ai/dsh-client-ui-conversation",
57
56
  "@deepseek-ai/dsh-client-ui-settings"
58
57
  ],
@@ -71,9 +70,9 @@
71
70
  "license": "Apache-2.0",
72
71
  "peerDependencies": {
73
72
  "@deepseek-ai/cordis": "^4.0.1",
74
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
75
- "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
76
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
73
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.7",
74
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.7",
75
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.7",
77
76
  "@deepseek-ai/schemastery": "^3.18.1",
78
77
  "react": "^18.3.1",
79
78
  "zod": "^4.4.3"
@@ -88,10 +87,10 @@
88
87
  },
89
88
  "devDependencies": {
90
89
  "@deepseek-ai/cordis": "^4.0.1",
91
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
92
- "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
93
- "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.7",
94
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
90
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
91
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
92
+ "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
93
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
95
94
  "@deepseek-ai/schemastery": "^3.18.1",
96
95
  "@stylistic/eslint-plugin": "^5.10.0",
97
96
  "@types/node": "^26.2.0",