dsh-plugin-sessiongraph-tool 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * Model-facing `sessiongraph` tool family over `ctx.sessiongraph`. The `sessiongraph` tool is
3
+ * read-only, with five operations — `search`, `session`, `timeline`, `workspaces`, `status` — that
4
+ * answer from a pre-built index of every local conversation. A second tool, `sessiongraph_index`,
5
+ * builds or refreshes that index on explicit request; it is separate so it can carry its own, much
6
+ * larger timeout budget than a query, because `defineTool`'s `timeoutMs` is fixed per registration,
7
+ * not per call, and one budget cannot fit both a millisecond query and a whole-machine index run.
8
+ *
9
+ * The tools own every default the seam refuses to guess — the per-operation result limits, the cap on
10
+ * a snippet — so the seam's requests stay fully specified and a deployment can retune what the model
11
+ * reads without touching a store. They inject only `tools`, `sessiongraph`, and `systemPrompt`, and
12
+ * import no store: which index answers is the seam's routing decision.
13
+ *
14
+ * Namespace plugin (named exports, no default export).
15
+ * @module dsh-plugin-sessiongraph-tool
16
+ */
17
+ import { homedir } from 'node:os';
18
+ import { isAbsolute, join, resolve } from 'node:path';
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { defineTool } from '@deepseek-ai/dsh-tools';
21
+ import { INDEX_RELATIVE_PATH, SessiongraphError } from 'dsh-plugin-sessiongraph-service';
22
+ import { isBackgroundIndexReport } from 'dsh-plugin-sessiongraph-sessions';
23
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
24
+ import { registerCommands } from "./command.js";
25
+ import { LazyIndexRefresher } from "./refresh.js";
26
+ import { renderSessiongraph } from "./render.js";
27
+ import { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, } from "./schema.js";
28
+ import { indexStatusHint } from "./status-hint.js";
29
+ export { SESSIONGRAPH_DEFAULT_LIMITS, SESSIONGRAPH_ENTRY_KINDS, SESSIONGRAPH_INDEX_PARAMETERS, SESSIONGRAPH_OPERATIONS, SESSIONGRAPH_OUTPUT_SCHEMA, SESSIONGRAPH_PARAMETERS, SESSIONGRAPH_TOOL_OPERATIONS, } from "./schema.js";
30
+ export { clipText, formatBytes, formatDuration, formatLiveReplay, formatLiveSessions, formatTime, renderSessiongraph, STALE_NOTE, } from "./render.js";
31
+ export { LazyIndexRefresher } from "./refresh.js";
32
+ export { COMMAND_DEFINITION_ID, COMMAND_DESCRIPTION, COMMAND_INPUT_HINT, COMMAND_NAME, COMMAND_USAGE, INJECTED_NOTICE_PLUGIN, indexReportText, invocationWorkspace, parseCommand, registerCommands, runCommand, searchText, statusText, workspacesText, } from "./command.js";
33
+ export { indexStatusHint, readIndexStatus } from "./status-hint.js";
34
+ /** Cordis plugin name for loader diagnostics. */
35
+ export const name = 'tool-sessiongraph';
36
+ /** Services required by this plugin. */
37
+ export const inject = ['tools', 'sessiongraph', 'systemPrompt'];
38
+ /** Default directory the index lives in, before `~` expansion. */
39
+ export const DEFAULT_INDEX_ROOT = '~/.dsh/sessiongraph';
40
+ /** Default tool-call timeout budget (ms) for the query-side `sessiongraph` tool. */
41
+ export const DEFAULT_SESSIONGRAPH_TOOL_TIMEOUT_MS = 30_000;
42
+ /** Default timeout budget (ms) for the `sessiongraph_index` tool. Indexing every local session is a different order of work than a query. */
43
+ export const DEFAULT_SESSIONGRAPH_INDEX_TIMEOUT_MS = 300_000;
44
+ /** Largest `limit` honored when the plugin config names none. */
45
+ export const DEFAULT_MAX_LIMIT = 100;
46
+ /** Default character cap for one rendered snippet or session entry. */
47
+ export const DEFAULT_MAX_SNIPPET_CHARS = 600;
48
+ /**
49
+ * Default age (ms) at which a query refreshes the index before answering. The indexer keeps a
50
+ * brand-new *session* cheap to find by refreshing on session creation, but the session currently
51
+ * being written has no successor yet; five minutes is the point where "did we just say that" would
52
+ * otherwise land on an index that predates the conversation.
53
+ */
54
+ export const DEFAULT_FRESHNESS_THRESHOLD_MS = 300_000;
55
+ /** The stable system-prompt guidance positioning the session index against grepping session files. */
56
+ export const SESSIONGRAPH_PROMPT_TEXT = '查我自己过去的对话(「上次在哪个会话里说过/做过什么」)用 sessiongraph,不要 grep ~/.dsh/sessions:'
57
+ + '那里的会话按工作区散在子目录里,是 zstd 压缩的 JSONL,grep 读不到正文,解压全量也慢。'
58
+ + 'sessiongraph 从预建索引回答:search 跨会话全文检索(中文按双字切分,两字词即可命中,如「广告」)、'
59
+ + 'session 按 id 或唯一前缀打开一个会话、timeline 按时间列会话、workspaces 列工作区统计。'
60
+ + '索引超过几分钟没更新时,查询前会自动跑一次增量刷新(很快,失败也照常回答,只是结果可能少几条)。'
61
+ + '索引包含正在进行的会话,所以刚才说的话通常也能查到(取决于上次刷新时间);status 里的实时会话数会随会话启停变化,不是索引坏了。'
62
+ + '索引不存在时 status 仍能回答,其它操作会失败;要强制全量重建用 sessiongraph_index,它有独立的更长预算(日志很多时它会转成后台 job 并返回 jobId,用 job_output 取报告)。';
63
+ export const Config = z.object({
64
+ indexRoot: z.string().default(DEFAULT_INDEX_ROOT),
65
+ maxLimit: z.number().default(DEFAULT_MAX_LIMIT),
66
+ maxSnippetChars: z.number().default(DEFAULT_MAX_SNIPPET_CHARS),
67
+ freshnessThresholdMs: z.number().default(DEFAULT_FRESHNESS_THRESHOLD_MS),
68
+ timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SESSIONGRAPH_TOOL_TIMEOUT_MS),
69
+ indexTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SESSIONGRAPH_INDEX_TIMEOUT_MS),
70
+ });
71
+ /**
72
+ * Register both tools and the system-prompt guidance.
73
+ * @param ctx - the plugin context (must inject `tools`, `sessiongraph`, `systemPrompt`).
74
+ * @param config - the resolved plugin configuration.
75
+ */
76
+ export function apply(ctx, config) {
77
+ const resolved = config;
78
+ assertPositiveInteger('maxLimit', resolved.maxLimit);
79
+ assertPositiveInteger('maxSnippetChars', resolved.maxSnippetChars);
80
+ assertNonNegativeInteger('freshnessThresholdMs', resolved.freshnessThresholdMs);
81
+ assertTimer('timeoutMs', resolved.timeoutMs);
82
+ assertTimer('indexTimeoutMs', resolved.indexTimeoutMs);
83
+ if (resolved.indexRoot.trim() === '') {
84
+ throw new Error('tool-sessiongraph: indexRoot must be a non-empty directory path');
85
+ }
86
+ const indexRoot = expandHome(resolved.indexRoot);
87
+ const renderOptions = { maxSnippetChars: resolved.maxSnippetChars };
88
+ // Process-local, one instance per registration: a second query in this process joins the run in
89
+ // flight instead of starting a second incremental index. `sessiongraph_index` never goes through
90
+ // it — an explicit refresh is asked for, not decided by a clock.
91
+ const refresher = new LazyIndexRefresher({
92
+ indexRoot,
93
+ freshnessThresholdMs: resolved.freshnessThresholdMs,
94
+ index: (options, signal) => ctx.sessiongraph.index(indexRoot, options, signal),
95
+ });
96
+ // A function, not a string: the host re-evaluates it on every assembly, which is what lets the model
97
+ // learn whether an index exists for free instead of spending a `status` round-trip. It does one
98
+ // synchronous stat and never opens the index — see `status-hint.ts`.
99
+ ctx.systemPrompt.section({
100
+ name: 'tool:sessiongraph',
101
+ order: 112,
102
+ text: () => `${SESSIONGRAPH_PROMPT_TEXT}\n\n${indexStatusHint(indexRoot)}`,
103
+ });
104
+ ctx.tools.register(defineTool({
105
+ name: 'sessiongraph',
106
+ description: '检索本机全部 DSH 会话的历史对话(不是代码):跨会话全文搜索「上次在哪个会话里说过/做过什么」、按 id 打开一个会话、按时间列会话、列工作区统计、看索引状态。答案来自预建索引,比 grep ~/.dsh/sessions 更快也更完整(那里的 JSONL 是 zstd 压缩的,grep 读不到)。中文按两字词检索即可命中(如「广告」)。要建或刷新索引请调用 sessiongraph_index;没有索引时除 status 外都会失败。',
107
+ parameters: SESSIONGRAPH_PARAMETERS,
108
+ output: {
109
+ schema: SESSIONGRAPH_OUTPUT_SCHEMA,
110
+ render: (args, value) => [{ type: 'text', text: renderSessiongraph(args, value, renderOptions) }],
111
+ },
112
+ timeoutMs: resolved.timeoutMs,
113
+ async execute(args, exec) {
114
+ return runQuery(ctx, refresher, indexRoot, resolved, args, exec);
115
+ },
116
+ presentCall: args => ({
117
+ card: 'generic',
118
+ title: callTitle(args),
119
+ kind: 'search',
120
+ rawInput: args,
121
+ }),
122
+ }));
123
+ ctx.tools.register(defineTool({
124
+ name: 'sessiongraph_index',
125
+ description: '建立或刷新 sessiongraph 索引,让 sessiongraph 工具能检索本机会话历史。首次运行会扫描 ~/.dsh/sessions 下的全部会话,可能耗时几十秒到几分钟,所以它有独立的 300 秒预算,与查询的预算分开。源文件自上次索引后没有变化的会话会被跳过,重复调用很便宜;源会话消失时它的条目会从索引里移除。会话日志很多时,这次调用会转成宿主后台任务并立刻返回 jobId(status: started),用 job_output 取运行报告。',
126
+ parameters: SESSIONGRAPH_INDEX_PARAMETERS,
127
+ output: {
128
+ schema: SESSIONGRAPH_OUTPUT_SCHEMA,
129
+ render: (args, value) => [{ type: 'text', text: renderSessiongraph(args, value, renderOptions) }],
130
+ },
131
+ timeoutMs: resolved.indexTimeoutMs,
132
+ async execute(args, exec) {
133
+ return runIndex(ctx, indexRoot, args, exec);
134
+ },
135
+ presentCall: args => {
136
+ const subject = args.session_id ?? args.workspace;
137
+ return {
138
+ card: 'generic',
139
+ title: subject === undefined ? 'sessiongraph_index' : `sessiongraph_index ${subject}`,
140
+ kind: 'execute',
141
+ rawInput: args,
142
+ };
143
+ },
144
+ }));
145
+ // The human surface is optional: `ctx.inject` waits for the command registry when the host has
146
+ // one and silently does nothing when it does not, so a composition without it still gets both
147
+ // tools. Registered last so nothing about the model-facing surface depends on it.
148
+ registerCommands(ctx, {
149
+ indexRoot,
150
+ maxSnippetChars: resolved.maxSnippetChars,
151
+ });
152
+ }
153
+ /**
154
+ * The one-line label a pending call shows.
155
+ * @param args - the validated tool arguments.
156
+ * @returns the card title naming the operation and whichever subject the operation takes.
157
+ */
158
+ export function callTitle(args) {
159
+ const subject = args.query ?? args.session_id ?? args.workspace;
160
+ return subject === undefined ? `sessiongraph ${args.operation}` : `sessiongraph ${args.operation} ${subject}`;
161
+ }
162
+ /**
163
+ * Turn the model's snake_case arguments into the seam's camelCase request, applying the per-operation
164
+ * defaults the seam refuses to guess.
165
+ *
166
+ * Conditional requirements live here rather than in the parameter schema, because the parameter DSL
167
+ * has no per-operation conditionals: `search` needs `query`, `session` needs `session_id`, and a call
168
+ * missing either is rejected before it can reach a store.
169
+ * @param args - the validated tool arguments.
170
+ * @param indexRoot - absolute directory the index lives in.
171
+ * @param maxLimit - largest `limit` honored, whatever the model asked for.
172
+ * @returns the normalized request.
173
+ */
174
+ export function buildRequest(args, indexRoot, maxLimit) {
175
+ const offset = offsetOf(args.offset);
176
+ switch (args.operation) {
177
+ case 'search':
178
+ return {
179
+ operation: 'search',
180
+ indexRoot,
181
+ query: required(args, 'query', 'search'),
182
+ ...(args.workspace === undefined ? {} : { workspace: args.workspace }),
183
+ ...(args.after === undefined ? {} : { after: timestamp('after', args.after) }),
184
+ ...(args.before === undefined ? {} : { before: timestamp('before', args.before) }),
185
+ ...(args.kinds === undefined || args.kinds.length === 0 ? {} : { kinds: args.kinds }),
186
+ limit: bounded(args.limit, SESSIONGRAPH_DEFAULT_LIMITS.search, maxLimit),
187
+ ...(offset === undefined ? {} : { offset }),
188
+ };
189
+ case 'session':
190
+ return {
191
+ operation: 'session',
192
+ indexRoot,
193
+ sessionId: required(args, 'session_id', 'session'),
194
+ limit: bounded(args.limit, SESSIONGRAPH_DEFAULT_LIMITS.session, maxLimit),
195
+ ...(offset === undefined ? {} : { offset }),
196
+ };
197
+ case 'timeline':
198
+ return {
199
+ operation: 'timeline',
200
+ indexRoot,
201
+ ...(args.workspace === undefined ? {} : { workspace: args.workspace }),
202
+ ...(args.after === undefined ? {} : { after: timestamp('after', args.after) }),
203
+ ...(args.before === undefined ? {} : { before: timestamp('before', args.before) }),
204
+ limit: bounded(args.limit, SESSIONGRAPH_DEFAULT_LIMITS.timeline, maxLimit),
205
+ };
206
+ case 'workspaces':
207
+ return {
208
+ operation: 'workspaces',
209
+ indexRoot,
210
+ limit: bounded(args.limit, SESSIONGRAPH_DEFAULT_LIMITS.workspaces, maxLimit),
211
+ };
212
+ case 'status':
213
+ return { operation: 'status', indexRoot };
214
+ /* v8 ignore next 3 -- exhaustive over the parameter schema's closed operation enum. */
215
+ default:
216
+ throw new SessiongraphError(`unknown sessiongraph operation ${JSON.stringify(args.operation)}`, 'SESSIONGRAPH_INVALID_REQUEST');
217
+ }
218
+ }
219
+ /**
220
+ * Read a required string argument, rejecting an absent or blank value.
221
+ * @param args - the validated tool arguments.
222
+ * @param field - the argument name the operation requires.
223
+ * @param operation - the operation being built, named in the failure.
224
+ * @returns the non-empty value.
225
+ */
226
+ function required(args, field, operation) {
227
+ const value = args[field];
228
+ if (value === undefined || value.trim() === '') {
229
+ throw new SessiongraphError(`the sessiongraph "${operation}" operation requires a non-empty "${field}"`, 'SESSIONGRAPH_INVALID_REQUEST');
230
+ }
231
+ return value;
232
+ }
233
+ /**
234
+ * Read an epoch-millisecond filter, rejecting a value that is not a finite number.
235
+ * @param field - the filter name, named in the failure.
236
+ * @param value - the model-supplied number.
237
+ * @returns the truncated timestamp.
238
+ */
239
+ function timestamp(field, value) {
240
+ if (!Number.isFinite(value)) {
241
+ throw new SessiongraphError(`the sessiongraph "${field}" filter must be a finite epoch-millisecond timestamp`, 'SESSIONGRAPH_INVALID_REQUEST');
242
+ }
243
+ return Math.trunc(value);
244
+ }
245
+ /**
246
+ * Read a paging offset, dropping values that cannot mean one.
247
+ * @param value - the model-supplied offset.
248
+ * @returns the non-negative integer offset, or `undefined` when the model named none.
249
+ */
250
+ function offsetOf(value) {
251
+ if (value === undefined || !Number.isFinite(value))
252
+ return undefined;
253
+ return Math.max(0, Math.trunc(value));
254
+ }
255
+ /**
256
+ * Clamp a model-supplied bound into the configured range. The per-operation default is the tool's own
257
+ * choice and is returned untouched: `maxLimit` bounds what the *model* asks for, so a deployment that
258
+ * lowers it never silently shrinks an operation's designed default (a session's 200 entries) into
259
+ * something the operation was not meant to answer with.
260
+ */
261
+ function bounded(value, fallback, max) {
262
+ if (value === undefined || !Number.isFinite(value))
263
+ return fallback;
264
+ const cap = Math.max(1, Math.trunc(max));
265
+ return Math.min(cap, Math.max(1, Math.trunc(value)));
266
+ }
267
+ /**
268
+ * Answer one query call.
269
+ *
270
+ * The request is validated first (a bad call must not spend a refresh), then the index is brought up
271
+ * to date if the sidecar says it is older than the freshness threshold, and only then is the query
272
+ * issued. Every refresh problem is non-fatal by construction: {@link LazyIndexRefresher.ensureFresh}
273
+ * never rejects, and its outcome only adds the optional `stale` marker to the answer.
274
+ * @param ctx - the plugin context.
275
+ * @param refresher - the process-local lazy refresh coordinator for this index root.
276
+ * @param indexRoot - absolute directory the index lives in.
277
+ * @param config - the resolved plugin configuration.
278
+ * @param args - the validated tool arguments.
279
+ * @param exec - the tool-execution context.
280
+ * @returns the canonical value for the requested operation.
281
+ */
282
+ async function runQuery(ctx, refresher, indexRoot, config, args, exec) {
283
+ // `status` describes the index itself; refreshing first would make it report a state it just
284
+ // changed, and the model uses it to decide whether to call `sessiongraph_index` at all.
285
+ if (args.operation === 'status')
286
+ return statusValue(ctx, indexRoot, exec.signal);
287
+ const request = buildRequest(args, indexRoot, config.maxLimit);
288
+ const refresh = await refresher.ensureFresh(exec.signal);
289
+ try {
290
+ const result = await ctx.sessiongraph.query(request, exec.signal);
291
+ return projectResult(result, refresh.stale);
292
+ }
293
+ catch (error) {
294
+ throw explainQueryFailure(error, indexRoot);
295
+ }
296
+ }
297
+ /**
298
+ * The `status` answer, including the one case the seam may not answer: when no store claims the root
299
+ * because no index file exists, "there is no index" is still an honest status, so the tool reports it
300
+ * instead of failing the call.
301
+ * @param ctx - the plugin context.
302
+ * @param indexRoot - absolute directory the index lives in.
303
+ * @param signal - aborts the query.
304
+ * @returns the canonical `status` value.
305
+ */
306
+ async function statusValue(ctx, indexRoot, signal) {
307
+ try {
308
+ return projectStatus(await ctx.sessiongraph.query({ operation: 'status', indexRoot }, signal));
309
+ }
310
+ catch (error) {
311
+ if (error instanceof SessiongraphError && error.code === 'SESSIONGRAPH_UNAVAILABLE') {
312
+ return {
313
+ operation: 'status',
314
+ indexed: false,
315
+ index_path: join(indexRoot, INDEX_RELATIVE_PATH),
316
+ sessions: 0,
317
+ entries: 0,
318
+ workspaces: 0,
319
+ live_sessions: 0,
320
+ bytes: 0,
321
+ };
322
+ }
323
+ throw error;
324
+ }
325
+ }
326
+ /**
327
+ * Translate a seam failure into something a model can act on. A `SESSIONGRAPH_UNAVAILABLE` on a query
328
+ * means the index has never been built — the raw "no store indexes this root" wording reads like a
329
+ * broken installation, while the actionable answer is one tool call away.
330
+ * @param error - whatever the seam threw.
331
+ * @param indexRoot - absolute directory the index lives in.
332
+ * @returns the error to throw: a clearer one, or the original when it needs no translation.
333
+ */
334
+ function explainQueryFailure(error, indexRoot) {
335
+ if (!(error instanceof SessiongraphError))
336
+ return error;
337
+ switch (error.code) {
338
+ case 'SESSIONGRAPH_UNAVAILABLE':
339
+ return new SessiongraphError(`还没有索引(${indexRoot}),请先调用 sessiongraph_index 建一次;建好之前除 status 外的查询都会失败。`, error.code);
340
+ case 'SESSIONGRAPH_NOT_FOUND':
341
+ return new SessiongraphError(`索引里没有这个会话(${error.message})。先用 search 或 timeline 找到正确的 id。`, error.code);
342
+ case 'SESSIONGRAPH_CORRUPT':
343
+ return new SessiongraphError(`${error.message}。索引文件损坏或版本不认识,用 sessiongraph_index 的 force 重建。`, error.code);
344
+ default:
345
+ return error;
346
+ }
347
+ }
348
+ /**
349
+ * Build or refresh the on-disk index. The dedicated tool this backs carries its own, much larger
350
+ * timeout budget than a query — indexing every session on the machine is a different order of work.
351
+ * @param ctx - the plugin context.
352
+ * @param indexRoot - absolute directory the index lives in.
353
+ * @param args - the index tool's validated arguments.
354
+ * @param exec - the tool-execution context.
355
+ * @returns the index operation's canonical value.
356
+ */
357
+ async function runIndex(ctx, indexRoot, args, exec) {
358
+ // The owning session id is a runtime extension of the seam's options, not part of its type: a
359
+ // job-capable indexer hands a large run to the host's job registry only when the caller names an
360
+ // owner, and this is the one call site that should allow it. Every indexer that does not read it
361
+ // simply runs in place, which is why this stays a plain optional property rather than a seam edit.
362
+ const options = {
363
+ ...(args.force === undefined ? {} : { force: args.force }),
364
+ ...(args.workspace === undefined ? {} : { workspace: args.workspace }),
365
+ ...(args.session_id === undefined ? {} : { sessionId: args.session_id }),
366
+ ...(exec.agent === undefined ? {} : { owner: exec.agent.id }),
367
+ };
368
+ try {
369
+ const report = await ctx.sessiongraph.index(indexRoot, options, exec.signal);
370
+ // The seam types `index()` as returning a report, so a delegated run comes back as a report
371
+ // carrying `jobId`; the indexer's own guard is the one place that distinction is defined.
372
+ return isBackgroundIndexReport(report) ? projectIndexStarted(report) : projectIndexReport(report);
373
+ }
374
+ catch (error) {
375
+ if (error instanceof SessiongraphError && error.code === 'SESSIONGRAPH_UNAVAILABLE') {
376
+ throw new SessiongraphError(`没有可用的 sessiongraph 索引器(${error.message}):indexer 插件可能没装,或不能写 ${indexRoot}。`, error.code);
377
+ }
378
+ throw error;
379
+ }
380
+ }
381
+ /**
382
+ * Project one seam result into the model-facing value its operation declares.
383
+ * @param result - the seam's answer.
384
+ * @param stale - whether the answer may predate the conversation because the refresh did not finish.
385
+ * @returns the canonical value for that operation.
386
+ */
387
+ function projectResult(result, stale) {
388
+ switch (result.operation) {
389
+ case 'search': return projectSearch(result, stale);
390
+ case 'session': return projectSession(result, stale);
391
+ case 'timeline': return projectTimeline(result, stale);
392
+ case 'workspaces': return projectWorkspaces(result, stale);
393
+ case 'status': return projectStatus(result);
394
+ }
395
+ }
396
+ /** The optional staleness marker, present only when the answer could be behind the conversation. */
397
+ function staleField(stale) {
398
+ return stale ? { stale: true } : {};
399
+ }
400
+ /**
401
+ * Project a search answer, dropping the optional fields the seam leaves `undefined` instead of
402
+ * emitting a JSON null the output schema would reject.
403
+ * @param result - the seam's answer.
404
+ * @param stale - whether the answer may be behind the conversation.
405
+ * @returns the canonical `search` value.
406
+ */
407
+ function projectSearch(result, stale) {
408
+ return {
409
+ operation: 'search',
410
+ hits: result.hits.map(hit => ({
411
+ session_id: hit.sessionId,
412
+ ...(hit.title === undefined ? {} : { title: hit.title }),
413
+ workspace: hit.workspace,
414
+ time: hit.time,
415
+ kind: hit.kind,
416
+ seq: hit.seq,
417
+ ...(hit.turn === undefined ? {} : { turn: hit.turn }),
418
+ snippet: hit.snippet,
419
+ length: hit.length,
420
+ })),
421
+ total: result.total,
422
+ truncated: result.truncated,
423
+ ...staleField(stale),
424
+ };
425
+ }
426
+ /**
427
+ * Project a session answer.
428
+ * @param result - the seam's answer.
429
+ * @param stale - whether the answer may be behind the conversation.
430
+ * @returns the canonical `session` value.
431
+ */
432
+ function projectSession(result, stale) {
433
+ return {
434
+ operation: 'session',
435
+ session: projectSummary(result.session),
436
+ entries: result.entries.map(entry => ({
437
+ seq: entry.seq,
438
+ time: entry.time,
439
+ kind: entry.kind,
440
+ ...(entry.turn === undefined ? {} : { turn: entry.turn }),
441
+ text: entry.text,
442
+ clipped: entry.clipped,
443
+ })),
444
+ children: [...result.children],
445
+ truncated: result.truncated,
446
+ ...staleField(stale),
447
+ };
448
+ }
449
+ /**
450
+ * Project a timeline answer.
451
+ * @param result - the seam's answer.
452
+ * @param stale - whether the answer may be behind the conversation.
453
+ * @returns the canonical `timeline` value.
454
+ */
455
+ function projectTimeline(result, stale) {
456
+ return {
457
+ operation: 'timeline',
458
+ sessions: result.sessions.map(session => projectSummary(session)),
459
+ truncated: result.truncated,
460
+ ...staleField(stale),
461
+ };
462
+ }
463
+ /**
464
+ * Project a workspaces answer.
465
+ * @param result - the seam's answer.
466
+ * @param stale - whether the answer may be behind the conversation.
467
+ * @returns the canonical `workspaces` value.
468
+ */
469
+ function projectWorkspaces(result, stale) {
470
+ return {
471
+ operation: 'workspaces',
472
+ workspaces: result.workspaces.map(workspace => ({
473
+ workspace: workspace.workspace,
474
+ sessions: workspace.sessions,
475
+ entries: workspace.entries,
476
+ first_at: workspace.firstAt,
477
+ last_at: workspace.lastAt,
478
+ })),
479
+ total: result.total,
480
+ ...staleField(stale),
481
+ };
482
+ }
483
+ /**
484
+ * Project a status answer.
485
+ * @param result - the seam's answer.
486
+ * @returns the canonical `status` value.
487
+ */
488
+ function projectStatus(result) {
489
+ return {
490
+ operation: 'status',
491
+ indexed: result.indexed,
492
+ index_path: result.indexPath,
493
+ sessions: result.sessions,
494
+ entries: result.entries,
495
+ workspaces: result.workspaces,
496
+ live_sessions: result.liveSessions ?? 0,
497
+ ...(result.indexedAt === undefined ? {} : { indexed_at: result.indexedAt }),
498
+ bytes: result.bytes,
499
+ };
500
+ }
501
+ /**
502
+ * Project one session's metadata.
503
+ * @param summary - the seam's summary.
504
+ * @returns the model-facing session object.
505
+ */
506
+ function projectSummary(summary) {
507
+ return {
508
+ session_id: summary.sessionId,
509
+ ...(summary.title === undefined ? {} : { title: summary.title }),
510
+ workspace: summary.workspace,
511
+ created_at: summary.createdAt,
512
+ last_at: summary.lastAt,
513
+ turns: summary.turns,
514
+ entries: summary.entries,
515
+ ...(summary.parentSession === undefined ? {} : { parent_session: summary.parentSession }),
516
+ ...(summary.origin === undefined ? {} : { origin: summary.origin }),
517
+ ...(summary.agentPreset === undefined ? {} : { agent_preset: summary.agentPreset }),
518
+ };
519
+ }
520
+ /**
521
+ * Project one finished index run.
522
+ * @param report - the indexer's report.
523
+ * @returns the canonical `index` value.
524
+ */
525
+ function projectIndexReport(report) {
526
+ return {
527
+ operation: 'index',
528
+ index_path: report.indexPath,
529
+ added: report.added,
530
+ updated: report.updated,
531
+ skipped: report.skipped,
532
+ removed: report.removed,
533
+ discovered: report.discovered,
534
+ failed: report.failed.map(entry => ({ path: entry.path, reason: entry.reason })),
535
+ live_sessions: report.liveSessions ?? 0,
536
+ entries: report.entries,
537
+ sessions: report.sessions,
538
+ workspaces: report.workspaces,
539
+ bytes: report.bytes,
540
+ elapsed_ms: report.elapsedMs,
541
+ };
542
+ }
543
+ /**
544
+ * Project one delegated index run: the call started a job and has no report of its own.
545
+ * @param report - the indexer's background report.
546
+ * @returns the canonical `index` value's started member.
547
+ */
548
+ function projectIndexStarted(report) {
549
+ return { operation: 'index', status: 'started', job_id: report.jobId };
550
+ }
551
+ /**
552
+ * Expand a configured index root: a leading `~` becomes the home directory, and a relative path is
553
+ * resolved against the process working directory so the value handed to the seam is always absolute.
554
+ * @param path - the configured path.
555
+ * @returns the absolute path.
556
+ */
557
+ function expandHome(path) {
558
+ if (path === '~')
559
+ return homedir();
560
+ if (path.startsWith('~/'))
561
+ return join(homedir(), path.slice(2));
562
+ return isAbsolute(path) ? path : resolve(path);
563
+ }
564
+ /** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
565
+ function assertPositiveInteger(field, value) {
566
+ if (!Number.isInteger(value) || value < 1) {
567
+ throw new Error(`tool-sessiongraph: ${field} must be a positive integer`);
568
+ }
569
+ }
570
+ /** Reject a config value that must be a whole number but may legitimately be zero (0 = disabled). */
571
+ function assertNonNegativeInteger(field, value) {
572
+ if (!Number.isInteger(value) || value < 0) {
573
+ throw new Error(`tool-sessiongraph: ${field} must be a non-negative integer`);
574
+ }
575
+ }
576
+ /** Reject a timer value Node would clamp instead of scheduling as configured. */
577
+ function assertTimer(field, value) {
578
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
579
+ throw new Error(`tool-sessiongraph: ${field} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
580
+ }
581
+ }
582
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-plugin-sessiongraph-tool`.
3
+ * @module dsh-plugin-sessiongraph-tool/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "tool-sessiongraph-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Package-owned invariant companion for `dsh-plugin-sessiongraph-tool`.
3
+ * @module dsh-plugin-sessiongraph-tool/invariant
4
+ */
5
+ const PACKAGE_NAME = 'dsh-plugin-sessiongraph-tool';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'tool-sessiongraph-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: this stateless adapter contributes two tools and one prompt section, while
12
+ * index contents and query answers remain owned by the session-graph seam it composes.
13
+ */
14
+ const install = () => { };
15
+ /**
16
+ * Register this package's invariant companion.
17
+ * @param ctx - Cordis context carrying the invariant service.
18
+ * @returns the installed registration's disposer after setup succeeds.
19
+ */
20
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
21
+ /* jscpd:ignore-end */
22
+ //# sourceMappingURL=invariant.js.map