dsh-plugin-sessiongraph-tool 0.1.15 → 0.1.16

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.d.ts CHANGED
@@ -18,6 +18,7 @@ import type { Context } from '@deepseek-ai/cordis';
18
18
  import z from '@deepseek-ai/schemastery';
19
19
  import type { SessiongraphRequest, SessiongraphService } from 'dsh-plugin-sessiongraph-service';
20
20
  import type { SessiongraphToolArgs } from './schema.ts';
21
+ import type { UpdateChecker } from './version-check.ts';
21
22
  declare module '@deepseek-ai/cordis' {
22
23
  interface Context {
23
24
  sessiongraph: SessiongraphService;
@@ -34,6 +35,8 @@ export { indexStatusHint, readIndexStatus } from './status-hint.ts';
34
35
  export type { IndexStatusFile } from './status-hint.ts';
35
36
  /** Cordis plugin name for loader diagnostics. */
36
37
  export declare const name = "tool-sessiongraph";
38
+ /** The npm package this code ships as; the registry is asked about this name's own releases. */
39
+ export declare const PACKAGE_NAME = "dsh-plugin-sessiongraph";
37
40
  /** Services required by this plugin. */
38
41
  export declare const inject: string[];
39
42
  /** Default directory the index lives in, before `~` expansion. */
@@ -79,6 +82,19 @@ export interface Config {
79
82
  timeoutMs?: number;
80
83
  /** Tool-call timeout budget in ms for the `sessiongraph_index` tool (default 300000). */
81
84
  indexTimeoutMs?: number;
85
+ /**
86
+ * Whether to ask the registry for a newer release after this plugin's tools are used, and append one
87
+ * notice to the next step when there is one (default true). The notice is a user-role message, so it
88
+ * reaches the model and the user both; it is emitted once per release and never blocks a tool call.
89
+ * Turn this off in an offline or air-gapped deployment, where the check can only time out.
90
+ */
91
+ updateCheck?: boolean;
92
+ /**
93
+ * Milliseconds a completed check stays trusted before the next one may start (default six hours).
94
+ * Shorter intervals mean a notice lands sooner after a release; the request itself is one small
95
+ * registry GET, so the cost of a shorter interval is latency nobody waits on, not tokens.
96
+ */
97
+ updateCheckIntervalMs?: number;
82
98
  }
83
99
  export declare const Config: z<Config>;
84
100
  /**
@@ -87,6 +103,16 @@ export declare const Config: z<Config>;
87
103
  * @param config - the resolved plugin configuration.
88
104
  */
89
105
  export declare function apply(ctx: Context, config: Config): void;
106
+ /**
107
+ * Append the "a newer release exists" notice to the next admitted step, once per release.
108
+ *
109
+ * Registered per agent rather than globally: the hook is agent-scoped in the host, its `next` only
110
+ * means something inside that scope, and one registration per conversation is what keeps a notice
111
+ * out of a session that never called the tool.
112
+ * @param ctx - the plugin context.
113
+ * @param checker - the shared checker the tool calls already report into.
114
+ */
115
+ export declare function registerUpdateNotice(ctx: Context, checker: UpdateChecker): void;
90
116
  /**
91
117
  * The one-line label a pending call shows.
92
118
  * @param args - the validated tool arguments.
package/lib/index.js CHANGED
@@ -25,7 +25,8 @@ import { registerCommands } from "./command.js";
25
25
  import { LazyIndexRefresher } from "./refresh.js";
26
26
  import { renderSessiongraph } from "./render.js";
27
27
  import { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, } from "./schema.js";
28
- import { indexStatusHint } from "./status-hint.js";
28
+ import { agentIdOf, indexStatusHint } from "./status-hint.js";
29
+ import { createUpdateChecker, defaultStateFile, DEFAULT_UPDATE_CHECK_INTERVAL_MS, readOwnVersion } from "./version-check.js";
29
30
  export { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_ENTRY_KINDS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OPERATIONS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, SESSIONGRAPH_TOOL_OPERATIONS, } from "./schema.js";
30
31
  export { clipText, formatBytes, formatDuration, formatLiveReplay, formatLiveSessions, formatTime, renderSessiongraph, STALE_NOTE, } from "./render.js";
31
32
  export { LazyIndexRefresher } from "./refresh.js";
@@ -33,6 +34,8 @@ export { COMMAND_DEFINITION_ID, COMMAND_DESCRIPTION, COMMAND_INPUT_HINT, COMMAND
33
34
  export { indexStatusHint, readIndexStatus } from "./status-hint.js";
34
35
  /** Cordis plugin name for loader diagnostics. */
35
36
  export const name = 'tool-sessiongraph';
37
+ /** The npm package this code ships as; the registry is asked about this name's own releases. */
38
+ export const PACKAGE_NAME = 'dsh-plugin-sessiongraph';
36
39
  /** Services required by this plugin. */
37
40
  export const inject = ['tools', 'sessiongraph', 'systemPrompt'];
38
41
  /** Default directory the index lives in, before `~` expansion. */
@@ -68,6 +71,8 @@ export const Config = z.object({
68
71
  injectCommandResults: z.boolean().default(false),
69
72
  timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SESSIONGRAPH_TOOL_TIMEOUT_MS),
70
73
  indexTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SESSIONGRAPH_INDEX_TIMEOUT_MS),
74
+ updateCheck: z.boolean().default(true),
75
+ updateCheckIntervalMs: z.number().default(DEFAULT_UPDATE_CHECK_INTERVAL_MS),
71
76
  });
72
77
  /**
73
78
  * Register both tools and the system-prompt guidance.
@@ -86,6 +91,17 @@ export function apply(ctx, config) {
86
91
  }
87
92
  const indexRoot = expandHome(resolved.indexRoot);
88
93
  const renderOptions = { maxSnippetChars: resolved.maxSnippetChars };
94
+ // Version checking is a convenience, never a dependency: turned off, or with an unreachable
95
+ // registry, the tools behave exactly as before. It only ever speaks after one of them ran — see
96
+ // `version-check.ts` for why that gate exists and what keeps it from repeating.
97
+ const updateChecker = resolved.updateCheck
98
+ ? createUpdateChecker({
99
+ packageName: PACKAGE_NAME,
100
+ currentVersion: readOwnVersion(),
101
+ stateFile: defaultStateFile(PACKAGE_NAME),
102
+ intervalMs: resolved.updateCheckIntervalMs,
103
+ })
104
+ : undefined;
89
105
  // Process-local, one instance per registration: a second query in this process joins the run in
90
106
  // flight instead of starting a second incremental index. `sessiongraph_index` never goes through
91
107
  // it — an explicit refresh is asked for, not decided by a clock.
@@ -112,6 +128,7 @@ export function apply(ctx, config) {
112
128
  },
113
129
  timeoutMs: resolved.timeoutMs,
114
130
  async execute(args, exec) {
131
+ updateChecker?.noteToolUse(agentIdOf(exec));
115
132
  return runQuery(ctx, refresher, indexRoot, resolved, args, exec);
116
133
  },
117
134
  presentCall: args => ({
@@ -131,6 +148,7 @@ export function apply(ctx, config) {
131
148
  },
132
149
  timeoutMs: resolved.indexTimeoutMs,
133
150
  async execute(args, exec) {
151
+ updateChecker?.noteToolUse(agentIdOf(exec));
134
152
  return runIndex(ctx, indexRoot, args, exec);
135
153
  },
136
154
  presentCall: args => {
@@ -151,6 +169,42 @@ export function apply(ctx, config) {
151
169
  maxSnippetChars: resolved.maxSnippetChars,
152
170
  injectResults: resolved.injectCommandResults,
153
171
  });
172
+ // The notice rides the host's step-admission waterfall: `agent/pre-step` is the one surface a
173
+ // plugin has between a tool result and the next model request, and it is where the host itself
174
+ // inserts its `[model changed: …]` notice. Registering it here (rather than at module scope) keeps
175
+ // the listener tied to this plugin instance's lifetime.
176
+ if (updateChecker !== undefined)
177
+ registerUpdateNotice(ctx, updateChecker);
178
+ }
179
+ /**
180
+ * Append the "a newer release exists" notice to the next admitted step, once per release.
181
+ *
182
+ * Registered per agent rather than globally: the hook is agent-scoped in the host, its `next` only
183
+ * means something inside that scope, and one registration per conversation is what keeps a notice
184
+ * out of a session that never called the tool.
185
+ * @param ctx - the plugin context.
186
+ * @param checker - the shared checker the tool calls already report into.
187
+ */
188
+ export function registerUpdateNotice(ctx, checker) {
189
+ const events = ctx;
190
+ // Registered inside this effect so the subscription follows the plugin's own lifetime: unloading
191
+ // the plugin must stop future agents from calling into a checker that no longer exists.
192
+ ctx.effect(() => events.on('agent/created', payload => {
193
+ const agentCtx = payload.agent?.ctx;
194
+ if (agentCtx === undefined)
195
+ return;
196
+ const agentId = agentIdOf(payload);
197
+ agentCtx.on('agent/pre-step', async (_payload, next) => {
198
+ const decision = await next();
199
+ // A rejected step sends nothing, so there is no conversation to put a notice into.
200
+ if (decision.kind !== 'enter')
201
+ return decision;
202
+ const notice = checker.takeNotice(agentId);
203
+ if (notice === undefined)
204
+ return decision;
205
+ return { ...decision, messages: [...decision.messages, notice] };
206
+ });
207
+ }));
154
208
  }
155
209
  /**
156
210
  * The one-line label a pending call shows.
@@ -39,4 +39,14 @@ export declare function readIndexStatus(indexRoot: string): IndexStatusFile | un
39
39
  * @returns one sentence naming the state, or what the model should do about it.
40
40
  */
41
41
  export declare function indexStatusHint(indexRoot: string): string;
42
+ /**
43
+ * The session identity a tool call or prompt assembly is running for.
44
+ *
45
+ * Read structurally rather than through `@deepseek-ai/dsh-agent`: this module needs one string off
46
+ * the payload, and a static import of a host symbol is exactly what took this plugin's sibling off
47
+ * the host once (the export moved between minor releases).
48
+ * @param context - the tool-execution or assembly context the host passes in (`{ agent, scope, signal }`).
49
+ * @returns the agent's id, or `undefined` when the payload carries none.
50
+ */
51
+ export declare function agentIdOf(context: unknown): string | undefined;
42
52
  //# sourceMappingURL=status-hint.d.ts.map
@@ -98,4 +98,18 @@ export function indexStatusHint(indexRoot) {
98
98
  return `索引文件已存在(${indexPath},${formatBytes(index.size)},${formatTime(index.mtimeMs)} 更新),`
99
99
  + '但没有 status 记录——规模与新鲜度用 sessiongraph status 查;怀疑过期时用 sessiongraph_index 刷新。';
100
100
  }
101
+ /**
102
+ * The session identity a tool call or prompt assembly is running for.
103
+ *
104
+ * Read structurally rather than through `@deepseek-ai/dsh-agent`: this module needs one string off
105
+ * the payload, and a static import of a host symbol is exactly what took this plugin's sibling off
106
+ * the host once (the export moved between minor releases).
107
+ * @param context - the tool-execution or assembly context the host passes in (`{ agent, scope, signal }`).
108
+ * @returns the agent's id, or `undefined` when the payload carries none.
109
+ */
110
+ export function agentIdOf(context) {
111
+ const agent = context?.agent;
112
+ const id = agent?.id;
113
+ return typeof id === 'string' && id !== '' ? id : undefined;
114
+ }
101
115
  //# sourceMappingURL=status-hint.js.map
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The one notice this plugin ever volunteers: the registry has a newer release than the one running.
3
+ *
4
+ * A plugin cannot upgrade itself — the package lives in a profile's dependency tree, and rewriting
5
+ * that tree from inside a session is the host's job, not a tool's. So the most useful thing it can do
6
+ * is say so, once, at a moment the user is already looking at its output.
7
+ *
8
+ * That moment is *after a tool ran*, which is why this module splits the work in two halves:
9
+ * {@link UpdateChecker.noteToolUse} is called from the tool's own `execute`, where a network request
10
+ * would sit inside the caller's timeout budget, and {@link UpdateChecker.takeNotice} is called from
11
+ * the `agent/pre-step` hook, which is synchronous by contract and runs before every model request. A
12
+ * check therefore starts in the background and only ever joins the conversation on a later step.
13
+ *
14
+ * Three things keep it from becoming noise:
15
+ *
16
+ * - **It only speaks after the plugin was used.** A notice the user did not ask for, on a step where
17
+ * they were doing something else, is the definition of spam. `noteToolUse` is the only way the
18
+ * pending flag is set.
19
+ * - **One notice per release.** `notifiedVersion` is persisted beside the check, so restarting the
20
+ * desktop does not re-announce a version the user already declined to install.
21
+ * - **Silence is the failure mode, never an error.** Every step is wrapped: a registry that is
22
+ * unreachable, a state file that is not writable, a body that is not JSON — all of them end as "no
23
+ * notice", because a check that cannot run must not cost the user a tool call.
24
+ *
25
+ * The message is built as a plain object rather than with `createUserMessage` from
26
+ * `@deepseek-ai/dsh-llm`: `dsh-llm` moves exports between minor releases (see this repo's CLAUDE.md),
27
+ * and this module needs one shape the host documents as merge-extensible. A static import of a named
28
+ * export that later moves takes the whole plugin out of the host; four lines of local typing do not.
29
+ * @module dsh-plugin-sessiongraph-tool/version-check
30
+ */
31
+ /** The npm project's own default registry — the last resort, used only when nothing else is configured. */
32
+ export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
33
+ /** How long a completed check is trusted before another one may start (six hours). */
34
+ export declare const DEFAULT_UPDATE_CHECK_INTERVAL_MS: number;
35
+ /** Bound on the registry request, so a hung socket cannot hold the one in-flight slot forever. */
36
+ export declare const UPDATE_CHECK_TIMEOUT_MS = 4000;
37
+ /** The durable half of a check: what the registry said, when, and what the user was already told. */
38
+ export interface UpdateState {
39
+ /** Newest version the registry advertised at `checkedAt`. */
40
+ readonly latest?: string;
41
+ /** Epoch milliseconds of the completed check, absent until one finishes. */
42
+ readonly checkedAt?: number;
43
+ /** Newest version a notice was already emitted for, so one release is announced once. */
44
+ readonly notifiedVersion?: string;
45
+ }
46
+ /**
47
+ * A message-shaped notice, structurally identical to what `dsh-llm`'s `createMessage` builds.
48
+ *
49
+ * `MessageSourceMap` is documented as merge-extensible (each producer declares its own `kind`), and
50
+ * `form: 'notice'` is the documented form for "a one-off account of something that just happened".
51
+ */
52
+ export interface UpdateNoticeMessage {
53
+ readonly id: string;
54
+ readonly role: 'user';
55
+ readonly content: readonly {
56
+ readonly type: 'text';
57
+ readonly text: string;
58
+ }[];
59
+ readonly source: {
60
+ readonly kind: string;
61
+ readonly form: 'notice';
62
+ readonly summary: string;
63
+ };
64
+ }
65
+ /** Collaborators a {@link UpdateChecker} needs; every one is injectable so tests need no network. */
66
+ export interface UpdateCheckerOptions {
67
+ /** The npm package this plugin ships as, e.g. `dsh-plugin-sessiongraph`. */
68
+ readonly packageName: string;
69
+ /**
70
+ * Version of the running build. Read from the package's own `package.json` by the caller rather
71
+ * than by this module, because a bundled deployment may not ship that file where `require` finds it.
72
+ */
73
+ readonly currentVersion: string;
74
+ /** State file; absent keeps the check entirely in memory (a restart re-announces, nothing breaks). */
75
+ readonly stateFile?: string;
76
+ /** Milliseconds a completed check stays trusted (default {@link DEFAULT_UPDATE_CHECK_INTERVAL_MS}). */
77
+ readonly intervalMs?: number;
78
+ /** Registry base URL (default {@link DEFAULT_REGISTRY}). */
79
+ readonly registry?: string;
80
+ /** Clock, for tests. */
81
+ readonly now?: () => number;
82
+ /** Registry reader, for tests; resolves the advertised version or `undefined`. */
83
+ readonly fetchLatest?: (packageName: string, registry: string, timeoutMs: number) => Promise<string | undefined>;
84
+ /** Writer, for tests; a throw is swallowed into "state not persisted". */
85
+ readonly writeState?: (file: string, state: UpdateState) => void;
86
+ /** Reader, for tests; a throw becomes the empty state. */
87
+ readonly readState?: (file: string) => UpdateState;
88
+ }
89
+ /** The two operations the host calls, and nothing else. */
90
+ export interface UpdateChecker {
91
+ /**
92
+ * Record that the plugin's tool ran, starting a background check when the last one is stale.
93
+ * @param agentId - the session that ran it, so the notice lands in that conversation rather than
94
+ * whichever one happens to assemble a request next. Omitted when the call carried no agent.
95
+ */
96
+ noteToolUse(agentId?: string): void;
97
+ /**
98
+ * The notice to append to this step, if the plugin has been used since the last one and the
99
+ * registry knows a newer release than the running one.
100
+ * @param agentId - the session assembling this step.
101
+ * @returns the message, or `undefined` when there is nothing to say.
102
+ */
103
+ takeNotice(agentId?: string): UpdateNoticeMessage | undefined;
104
+ /** The state a completed check settled on, for diagnostics and tests. */
105
+ peek(): UpdateState;
106
+ }
107
+ /**
108
+ * Build the checker. It never starts a request by itself: only {@link UpdateChecker.noteToolUse}
109
+ * does, so an installed plugin that is never called costs nothing.
110
+ * @param options - package identity plus the injectable collaborators.
111
+ * @returns the checker the tool registration and the pre-step hook share.
112
+ */
113
+ export declare function createUpdateChecker(options: UpdateCheckerOptions): UpdateChecker;
114
+ /**
115
+ * The registry a session should consult, resolved the way npm itself resolves it: an explicit
116
+ * `npm_config_registry` wins, then the user's `~/.npmrc`, and only then npm's own default.
117
+ *
118
+ * Hardcoding the public registry would check the wrong host in any deployment behind a mirror,
119
+ * where the package the user actually installed came from the mirror and the public one may be
120
+ * unreachable. Following the user's configuration means the answer we compare against is the answer
121
+ * their install would get.
122
+ * @param env - process environment, read for `npm_config_registry`.
123
+ * @param readText - reader for the user's `.npmrc`; injectable for tests.
124
+ * @param home - home directory holding `.npmrc`; injectable for tests.
125
+ * @returns the registry base URL, without a trailing slash.
126
+ */
127
+ export declare function resolveRegistry(env?: NodeJS.ProcessEnv, readText?: (file: string) => string | undefined, home?: string): string;
128
+ /**
129
+ * Pull the `registry=` line out of npmrc text.
130
+ *
131
+ * Only the plain key is read: a scoped override (`@scope:registry=…`) does not apply to an unscoped
132
+ * package, and the auth lines (`//host/:_authToken=…`) must never be mistaken for a base URL. Values
133
+ * may be quoted, which npmrc allows.
134
+ * @param text - file contents, or `undefined` when the file is absent.
135
+ * @returns the configured registry, or `undefined` when this file sets none.
136
+ */
137
+ export declare function parseNpmrcRegistry(text: string | undefined): string | undefined;
138
+ /**
139
+ * Read a text file, treating every failure as "the file does not exist".
140
+ * @param file - absolute path.
141
+ * @returns the contents, or `undefined`.
142
+ */
143
+ export declare function readTextFile(file: string): string | undefined;
144
+ /**
145
+ * Ask a registry for a package's `latest` dist-tag.
146
+ * @param packageName - package name; scoped names are encoded per the registry's own routing.
147
+ * @param registry - registry base URL.
148
+ * @param timeoutMs - request bound.
149
+ * @returns the advertised version, or `undefined` on any failure whatsoever.
150
+ */
151
+ export declare function fetchLatestFromRegistry(packageName: string, registry: string, timeoutMs: number): Promise<string | undefined>;
152
+ /**
153
+ * Encode a package name into one registry path segment, keeping the scope's `/` meaningful.
154
+ * @param packageName - bare or scoped package name.
155
+ * @returns the registry-safe path.
156
+ */
157
+ export declare function encodePackageName(packageName: string): string;
158
+ /**
159
+ * Compare two semantic versions, prerelease-aware.
160
+ *
161
+ * `1.0.0` outranks `1.0.0-rc.1`, which outranks `1.0.0-rc.0`; a numeric prerelease segment compares
162
+ * numerically while an alphanumeric one compares as text, which is what semver specifies and what
163
+ * decides whether an rc channel user is told about the next rc.
164
+ * @param a - left version.
165
+ * @param b - right version.
166
+ * @returns a negative number when `a < b`, zero when equal, positive when `a > b`.
167
+ */
168
+ export declare function compareVersions(a: string, b: string): number;
169
+ /**
170
+ * Bound a notice summary to what the host will store.
171
+ * @param summary - the account, of any length.
172
+ * @returns the account, ellipsized to {@link CONTEXT_SUMMARY_MAX_CHARS}.
173
+ */
174
+ export declare function boundSummary(summary: string): string;
175
+ /**
176
+ * Read a persisted state file.
177
+ * @param file - absolute path.
178
+ * @returns the state; an absent, unreadable, or unrecognizable file yields the empty state.
179
+ */
180
+ export declare function readStateFile(file: string): UpdateState;
181
+ /**
182
+ * Write a state file, creating it if needed.
183
+ * @param file - absolute path.
184
+ * @param state - the state to persist.
185
+ */
186
+ export declare function writeStateFile(file: string, state: UpdateState): void;
187
+ /**
188
+ * Where this plugin remembers its last check: beside the host's other per-user state, so it outlives
189
+ * the profile a package happens to be installed in.
190
+ *
191
+ * `~/.dsh` is the host's own directory and is writable in every deployment this plugin runs in; a
192
+ * failure to create or write the file is already handled by the caller, and simply means the notice
193
+ * may repeat after a restart.
194
+ * @param packageName - the npm package name, used to keep sibling plugins' state apart.
195
+ * @returns the absolute state-file path.
196
+ */
197
+ export declare function defaultStateFile(packageName: string, home?: string): string;
198
+ /**
199
+ * The version of the package this module was built into.
200
+ *
201
+ * Read from the package's own `package.json` relative to the built file rather than through
202
+ * `require`, because a deployment may bundle or relocate the code. `../package.json` resolves to the
203
+ * package root both from `src/` (tests) and from `lib/` (installed).
204
+ * @param fallback - returned when the file cannot be read or carries no version.
205
+ * @param readText - reader for the package manifest; injectable for tests.
206
+ * @returns the version string.
207
+ */
208
+ export declare function readOwnVersion(fallback?: string, readText?: (file: string) => string | undefined): string;
209
+ //# sourceMappingURL=version-check.d.ts.map
@@ -0,0 +1,434 @@
1
+ /**
2
+ * The one notice this plugin ever volunteers: the registry has a newer release than the one running.
3
+ *
4
+ * A plugin cannot upgrade itself — the package lives in a profile's dependency tree, and rewriting
5
+ * that tree from inside a session is the host's job, not a tool's. So the most useful thing it can do
6
+ * is say so, once, at a moment the user is already looking at its output.
7
+ *
8
+ * That moment is *after a tool ran*, which is why this module splits the work in two halves:
9
+ * {@link UpdateChecker.noteToolUse} is called from the tool's own `execute`, where a network request
10
+ * would sit inside the caller's timeout budget, and {@link UpdateChecker.takeNotice} is called from
11
+ * the `agent/pre-step` hook, which is synchronous by contract and runs before every model request. A
12
+ * check therefore starts in the background and only ever joins the conversation on a later step.
13
+ *
14
+ * Three things keep it from becoming noise:
15
+ *
16
+ * - **It only speaks after the plugin was used.** A notice the user did not ask for, on a step where
17
+ * they were doing something else, is the definition of spam. `noteToolUse` is the only way the
18
+ * pending flag is set.
19
+ * - **One notice per release.** `notifiedVersion` is persisted beside the check, so restarting the
20
+ * desktop does not re-announce a version the user already declined to install.
21
+ * - **Silence is the failure mode, never an error.** Every step is wrapped: a registry that is
22
+ * unreachable, a state file that is not writable, a body that is not JSON — all of them end as "no
23
+ * notice", because a check that cannot run must not cost the user a tool call.
24
+ *
25
+ * The message is built as a plain object rather than with `createUserMessage` from
26
+ * `@deepseek-ai/dsh-llm`: `dsh-llm` moves exports between minor releases (see this repo's CLAUDE.md),
27
+ * and this module needs one shape the host documents as merge-extensible. A static import of a named
28
+ * export that later moves takes the whole plugin out of the host; four lines of local typing do not.
29
+ * @module dsh-plugin-sessiongraph-tool/version-check
30
+ */
31
+ import { randomUUID } from 'node:crypto';
32
+ import { readFileSync, writeFileSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join } from 'node:path';
35
+ /** The npm project's own default registry — the last resort, used only when nothing else is configured. */
36
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
37
+ /** How long a completed check is trusted before another one may start (six hours). */
38
+ export const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
39
+ /** Bound on the registry request, so a hung socket cannot hold the one in-flight slot forever. */
40
+ export const UPDATE_CHECK_TIMEOUT_MS = 4000;
41
+ /** Pending-use key for a tool call that carried no agent identity (a headless or test invocation). */
42
+ const ANY_AGENT = '';
43
+ /**
44
+ * Build the checker. It never starts a request by itself: only {@link UpdateChecker.noteToolUse}
45
+ * does, so an installed plugin that is never called costs nothing.
46
+ * @param options - package identity plus the injectable collaborators.
47
+ * @returns the checker the tool registration and the pre-step hook share.
48
+ */
49
+ export function createUpdateChecker(options) {
50
+ const intervalMs = options.intervalMs ?? DEFAULT_UPDATE_CHECK_INTERVAL_MS;
51
+ // Resolved once per process: a registry the user changes mid-session is picked up on the next
52
+ // reload, and re-reading `.npmrc` on every call would be a file read nobody asked for.
53
+ const registry = (options.registry ?? resolveRegistry()).replace(/\/+$/, '');
54
+ const now = options.now ?? Date.now;
55
+ const fetchLatest = options.fetchLatest ?? fetchLatestFromRegistry;
56
+ const writeState = options.writeState ?? writeStateFile;
57
+ const readState = options.readState ?? readStateFile;
58
+ const kind = `${options.packageName}-update`;
59
+ let state = options.stateFile === undefined ? {} : readState(options.stateFile);
60
+ // Sessions whose next step should hear the result. A `Set` rather than a flag so one conversation
61
+ // running a query cannot make another one announce a version it never asked about.
62
+ const pendingAgents = new Set();
63
+ let inFlight = false;
64
+ /** Persist once per meaningful transition; a failure only costs a repeat notice after restart. */
65
+ function persist() {
66
+ if (options.stateFile === undefined)
67
+ return;
68
+ try {
69
+ writeState(options.stateFile, state);
70
+ }
71
+ catch {
72
+ // A read-only profile directory is a legitimate deployment, not an error worth surfacing.
73
+ }
74
+ }
75
+ /** One background check. Never rejects: its whole contract is "may set `latest`". */
76
+ async function runCheck() {
77
+ inFlight = true;
78
+ try {
79
+ const latest = await fetchLatest(options.packageName, registry, UPDATE_CHECK_TIMEOUT_MS);
80
+ // `checkedAt` advances even when the registry answered nothing, so an unreachable or 404-ing
81
+ // registry is retried once per interval instead of once per tool call.
82
+ state = latest === undefined
83
+ ? { ...state, checkedAt: now() }
84
+ : { ...state, latest, checkedAt: now() };
85
+ persist();
86
+ }
87
+ catch {
88
+ state = { ...state, checkedAt: now() };
89
+ }
90
+ finally {
91
+ inFlight = false;
92
+ }
93
+ }
94
+ return {
95
+ noteToolUse(agentId) {
96
+ pendingAgents.add(agentId ?? ANY_AGENT);
97
+ if (inFlight)
98
+ return;
99
+ if (state.checkedAt !== undefined && now() - state.checkedAt < intervalMs)
100
+ return;
101
+ // Deliberately not awaited: this runs inside a tool call whose timeout budget the user set for
102
+ // their own work, and the notice is not worth one millisecond of it.
103
+ void runCheck();
104
+ },
105
+ takeNotice(agentId) {
106
+ // No notice before the plugin was used in this session, and none while the answer is still on
107
+ // its way — the pending entry stays so the step after the check completes can still speak.
108
+ const key = agentId ?? ANY_AGENT;
109
+ if (!pendingAgents.has(key) && !pendingAgents.has(ANY_AGENT))
110
+ return undefined;
111
+ if (state.checkedAt === undefined || now() - state.checkedAt >= intervalMs)
112
+ return undefined;
113
+ // The check for this use has settled: either it found nothing, or it is about to be reported.
114
+ pendingAgents.delete(key);
115
+ pendingAgents.delete(ANY_AGENT);
116
+ const latest = state.latest;
117
+ if (latest === undefined)
118
+ return undefined;
119
+ if (compareVersions(latest, options.currentVersion) <= 0)
120
+ return undefined;
121
+ if (state.notifiedVersion === latest)
122
+ return undefined;
123
+ state = { ...state, notifiedVersion: latest };
124
+ persist();
125
+ return buildNotice(kind, options.packageName, options.currentVersion, latest);
126
+ },
127
+ peek() {
128
+ return state;
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * The registry a session should consult, resolved the way npm itself resolves it: an explicit
134
+ * `npm_config_registry` wins, then the user's `~/.npmrc`, and only then npm's own default.
135
+ *
136
+ * Hardcoding the public registry would check the wrong host in any deployment behind a mirror,
137
+ * where the package the user actually installed came from the mirror and the public one may be
138
+ * unreachable. Following the user's configuration means the answer we compare against is the answer
139
+ * their install would get.
140
+ * @param env - process environment, read for `npm_config_registry`.
141
+ * @param readText - reader for the user's `.npmrc`; injectable for tests.
142
+ * @param home - home directory holding `.npmrc`; injectable for tests.
143
+ * @returns the registry base URL, without a trailing slash.
144
+ */
145
+ export function resolveRegistry(env = process.env, readText = readTextFile, home = homedir()) {
146
+ const configured = (env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY ?? '').trim();
147
+ if (configured !== '')
148
+ return stripTrailingSlash(configured);
149
+ const fromFile = parseNpmrcRegistry(readText(join(home, '.npmrc')));
150
+ if (fromFile !== undefined)
151
+ return stripTrailingSlash(fromFile);
152
+ return DEFAULT_REGISTRY;
153
+ }
154
+ /** The npmrc key that sets the default registry, as opposed to a scoped or auth entry. */
155
+ const NPMRC_REGISTRY_KEY = 'registry';
156
+ /**
157
+ * Pull the `registry=` line out of npmrc text.
158
+ *
159
+ * Only the plain key is read: a scoped override (`@scope:registry=…`) does not apply to an unscoped
160
+ * package, and the auth lines (`//host/:_authToken=…`) must never be mistaken for a base URL. Values
161
+ * may be quoted, which npmrc allows.
162
+ * @param text - file contents, or `undefined` when the file is absent.
163
+ * @returns the configured registry, or `undefined` when this file sets none.
164
+ */
165
+ export function parseNpmrcRegistry(text) {
166
+ if (text === undefined)
167
+ return undefined;
168
+ for (const rawLine of text.split(/\r?\n/)) {
169
+ const line = rawLine.trim();
170
+ // npmrc comments are both `#` and `;`; an empty value means "not configured here".
171
+ if (line === '' || line.startsWith('#') || line.startsWith(';'))
172
+ continue;
173
+ const separator = line.indexOf('=');
174
+ if (separator === -1)
175
+ continue;
176
+ if (line.slice(0, separator).trim() !== NPMRC_REGISTRY_KEY)
177
+ continue;
178
+ const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, '');
179
+ if (value !== '')
180
+ return value;
181
+ }
182
+ return undefined;
183
+ }
184
+ /**
185
+ * Read a text file, treating every failure as "the file does not exist".
186
+ * @param file - absolute path.
187
+ * @returns the contents, or `undefined`.
188
+ */
189
+ export function readTextFile(file) {
190
+ try {
191
+ return readFileSync(file, 'utf8');
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
197
+ /**
198
+ * Drop trailing slashes so `${registry}/${name}/latest` never doubles a separator.
199
+ * @param registry - the configured base URL.
200
+ * @returns the URL without trailing slashes.
201
+ */
202
+ function stripTrailingSlash(registry) {
203
+ return registry.replace(/\/+$/, '');
204
+ }
205
+ /**
206
+ * Ask a registry for a package's `latest` dist-tag.
207
+ * @param packageName - package name; scoped names are encoded per the registry's own routing.
208
+ * @param registry - registry base URL.
209
+ * @param timeoutMs - request bound.
210
+ * @returns the advertised version, or `undefined` on any failure whatsoever.
211
+ */
212
+ export async function fetchLatestFromRegistry(packageName, registry, timeoutMs) {
213
+ // The `/latest` endpoint is the dist-tag, not the newest semver: an install follows the tag, so a
214
+ // notice that compared against a raw maximum would nag about prereleases nobody is installing.
215
+ const url = `${registry}/${encodePackageName(packageName)}/latest`;
216
+ try {
217
+ const response = await fetch(url, {
218
+ signal: AbortSignal.timeout(timeoutMs),
219
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
220
+ });
221
+ if (!response.ok)
222
+ return undefined;
223
+ const body = await response.json();
224
+ if (typeof body !== 'object' || body === null)
225
+ return undefined;
226
+ const version = body.version;
227
+ return typeof version === 'string' && version !== '' ? version : undefined;
228
+ }
229
+ catch {
230
+ // Unreachable network, DNS failure, abort, malformed body: all mean "nothing to report".
231
+ return undefined;
232
+ }
233
+ }
234
+ /**
235
+ * Encode a package name into one registry path segment, keeping the scope's `/` meaningful.
236
+ * @param packageName - bare or scoped package name.
237
+ * @returns the registry-safe path.
238
+ */
239
+ export function encodePackageName(packageName) {
240
+ return packageName.startsWith('@') ? `@${encodeURIComponent(packageName.slice(1)).replace(/%2F/gi, '/')}` : encodeURIComponent(packageName);
241
+ }
242
+ /**
243
+ * Compare two semantic versions, prerelease-aware.
244
+ *
245
+ * `1.0.0` outranks `1.0.0-rc.1`, which outranks `1.0.0-rc.0`; a numeric prerelease segment compares
246
+ * numerically while an alphanumeric one compares as text, which is what semver specifies and what
247
+ * decides whether an rc channel user is told about the next rc.
248
+ * @param a - left version.
249
+ * @param b - right version.
250
+ * @returns a negative number when `a < b`, zero when equal, positive when `a > b`.
251
+ */
252
+ export function compareVersions(a, b) {
253
+ const left = splitVersion(a);
254
+ const right = splitVersion(b);
255
+ // `as const` keeps the index a literal, so the tuple's three fields are read directly instead of
256
+ // through an index the compiler would have to widen to `number | undefined`.
257
+ for (const index of [0, 1, 2]) {
258
+ const difference = left.numbers[index] - right.numbers[index];
259
+ if (difference !== 0)
260
+ return difference;
261
+ }
262
+ // A release outranks any prerelease of the same core version.
263
+ if (left.prerelease.length === 0 && right.prerelease.length === 0)
264
+ return 0;
265
+ if (left.prerelease.length === 0)
266
+ return 1;
267
+ if (right.prerelease.length === 0)
268
+ return -1;
269
+ const depth = Math.max(left.prerelease.length, right.prerelease.length);
270
+ for (let index = 0; index < depth; index += 1) {
271
+ const one = left.prerelease[index];
272
+ const other = right.prerelease[index];
273
+ // A shorter prerelease is the lower one when all its segments matched (`1.0.0-rc` < `1.0.0-rc.1`).
274
+ if (one === undefined)
275
+ return -1;
276
+ if (other === undefined)
277
+ return 1;
278
+ if (one === other)
279
+ continue;
280
+ const numericOne = /^\d+$/.test(one);
281
+ const numericOther = /^\d+$/.test(other);
282
+ if (numericOne && numericOther)
283
+ return Number(one) - Number(other);
284
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
285
+ if (numericOne)
286
+ return -1;
287
+ if (numericOther)
288
+ return 1;
289
+ return one < other ? -1 : 1;
290
+ }
291
+ return 0;
292
+ }
293
+ /**
294
+ * Split a version into its three numeric fields and its prerelease segments.
295
+ * @param version - version string, optionally `v`-prefixed.
296
+ * @returns the parsed fields; anything unparseable counts as zero, which makes an unrecognizable
297
+ * published version compare as "older" and therefore never nag.
298
+ */
299
+ function splitVersion(version) {
300
+ const trimmed = version.trim().replace(/^v/i, '');
301
+ // Splitting on the first hyphen keeps a prerelease like `rc-1` intact; an empty core (`-rc.1`)
302
+ // yields no numeric fields, which reads as "zero".
303
+ const hyphen = trimmed.indexOf('-');
304
+ const core = hyphen === -1 ? trimmed : trimmed.slice(0, hyphen);
305
+ const parts = core.split('.');
306
+ const numbers = [
307
+ toNumber(parts[0]),
308
+ toNumber(parts[1]),
309
+ toNumber(parts[2]),
310
+ ];
311
+ const prerelease = hyphen === -1
312
+ ? []
313
+ : trimmed.slice(hyphen + 1).split('.').filter(part => part !== '');
314
+ return { numbers, prerelease };
315
+ }
316
+ /** One numeric version field, defaulting to zero. */
317
+ function toNumber(field) {
318
+ const parsed = Number(field);
319
+ return Number.isFinite(parsed) ? parsed : 0;
320
+ }
321
+ /**
322
+ * Build the notice the model and the user both see.
323
+ * @param kind - producer kind recorded on the message source.
324
+ * @param packageName - package to name in the upgrade instruction.
325
+ * @param currentVersion - version currently running.
326
+ * @param latest - version the registry advertised.
327
+ * @returns a frozen, message-shaped notice.
328
+ */
329
+ function buildNotice(kind, packageName, currentVersion, latest) {
330
+ const text = [
331
+ `[${packageName} 有新版本] 当前运行 ${currentVersion},npm 上最新为 ${latest}。`,
332
+ `升级方式:在 DSH 插件的 profile 目录执行 pnpm add ${packageName}@latest,然后重启 DSH。`,
333
+ '这条提示由插件自动发出,同一版本只提醒一次。',
334
+ ].join('');
335
+ return {
336
+ id: randomUUID(),
337
+ role: 'user',
338
+ content: [{ type: 'text', text }],
339
+ source: {
340
+ kind,
341
+ form: 'notice',
342
+ // The durable one-line account, bounded the way the host bounds every notice summary.
343
+ summary: boundSummary(`${packageName} ${currentVersion} → ${latest}`),
344
+ },
345
+ };
346
+ }
347
+ /** Longest notice summary the host persists (`CONTEXT_SUMMARY_MAX_CHARS`). */
348
+ const CONTEXT_SUMMARY_MAX_CHARS = 120;
349
+ /**
350
+ * Bound a notice summary to what the host will store.
351
+ * @param summary - the account, of any length.
352
+ * @returns the account, ellipsized to {@link CONTEXT_SUMMARY_MAX_CHARS}.
353
+ */
354
+ export function boundSummary(summary) {
355
+ return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
356
+ ? summary
357
+ : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`;
358
+ }
359
+ /**
360
+ * Read a persisted state file.
361
+ * @param file - absolute path.
362
+ * @returns the state; an absent, unreadable, or unrecognizable file yields the empty state.
363
+ */
364
+ export function readStateFile(file) {
365
+ let parsed;
366
+ try {
367
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
368
+ }
369
+ catch {
370
+ return {};
371
+ }
372
+ if (typeof parsed !== 'object' || parsed === null)
373
+ return {};
374
+ const record = parsed;
375
+ const latest = typeof record.latest === 'string' ? record.latest : undefined;
376
+ const checkedAt = typeof record.checkedAt === 'number' && Number.isFinite(record.checkedAt)
377
+ ? record.checkedAt
378
+ : undefined;
379
+ const notifiedVersion = typeof record.notifiedVersion === 'string' ? record.notifiedVersion : undefined;
380
+ return {
381
+ ...(latest === undefined ? {} : { latest }),
382
+ ...(checkedAt === undefined ? {} : { checkedAt }),
383
+ ...(notifiedVersion === undefined ? {} : { notifiedVersion }),
384
+ };
385
+ }
386
+ /**
387
+ * Write a state file, creating it if needed.
388
+ * @param file - absolute path.
389
+ * @param state - the state to persist.
390
+ */
391
+ export function writeStateFile(file, state) {
392
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
393
+ }
394
+ /**
395
+ * Where this plugin remembers its last check: beside the host's other per-user state, so it outlives
396
+ * the profile a package happens to be installed in.
397
+ *
398
+ * `~/.dsh` is the host's own directory and is writable in every deployment this plugin runs in; a
399
+ * failure to create or write the file is already handled by the caller, and simply means the notice
400
+ * may repeat after a restart.
401
+ * @param packageName - the npm package name, used to keep sibling plugins' state apart.
402
+ * @returns the absolute state-file path.
403
+ */
404
+ export function defaultStateFile(packageName, home = homedir()) {
405
+ const short = packageName.replace(/^dsh-plugin-/, '');
406
+ return join(home, '.dsh', `${short}-update-check.json`);
407
+ }
408
+ /**
409
+ * The version of the package this module was built into.
410
+ *
411
+ * Read from the package's own `package.json` relative to the built file rather than through
412
+ * `require`, because a deployment may bundle or relocate the code. `../package.json` resolves to the
413
+ * package root both from `src/` (tests) and from `lib/` (installed).
414
+ * @param fallback - returned when the file cannot be read or carries no version.
415
+ * @param readText - reader for the package manifest; injectable for tests.
416
+ * @returns the version string.
417
+ */
418
+ export function readOwnVersion(fallback = '0.0.0', readText = readTextFile) {
419
+ const raw = readText(new URL('../package.json', import.meta.url).pathname);
420
+ if (raw === undefined)
421
+ return fallback;
422
+ let parsed;
423
+ try {
424
+ parsed = JSON.parse(raw);
425
+ }
426
+ catch {
427
+ return fallback;
428
+ }
429
+ if (typeof parsed !== 'object' || parsed === null)
430
+ return fallback;
431
+ const version = parsed.version;
432
+ return typeof version === 'string' && version !== '' ? version : fallback;
433
+ }
434
+ //# sourceMappingURL=version-check.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-sessiongraph-tool",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Model-facing sessiongraph tools for DeepSeek Harness — a read-only sessiongraph tool that searches the whole local conversation history, plus a sessiongraph_index tool that builds or refreshes the index on its own, much larger timeout budget",
5
5
  "keywords": [
6
6
  "dsh",
@@ -50,8 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@deepseek-ai/schemastery": "^3.18.1",
53
- "dsh-plugin-sessiongraph-service": "^0.1.15",
54
- "dsh-plugin-sessiongraph-sessions": "^0.1.15"
53
+ "dsh-plugin-sessiongraph-service": "^0.1.16",
54
+ "dsh-plugin-sessiongraph-sessions": "^0.1.16"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",