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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CC ZHAO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The human `/sessiongraph` slash command.
3
+ *
4
+ * The model-facing tools answer the same questions, but a person at the composer should not spend a
5
+ * model turn asking "什么进索引了" or "上次谁提过广告". This command runs directly against
6
+ * `ctx.sessiongraph` in the UI's command plane: nothing it returns becomes a model message or a
7
+ * token.
8
+ *
9
+ * Registered through conditional injection (`ctx.inject(['commands'], …)`) — the command registry is
10
+ * a host composition choice, and its absence must not hide the two tools this plugin also registers.
11
+ * `@deepseek-ai/dsh-commands` is deliberately not imported: the service may be absent, so its public
12
+ * shape is mirrored structurally here, exactly as the codegraph command does.
13
+ * @module dsh-plugin-sessiongraph-tool/command
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis';
16
+ import type { UserMessage } from '@deepseek-ai/dsh-llm';
17
+ import type { SessiongraphIndexReport, SessiongraphSearchResult, SessiongraphStatusResult, SessiongraphWorkspacesResult } from 'dsh-plugin-sessiongraph-service';
18
+ /** Command name, without the leading slash. */
19
+ export declare const COMMAND_NAME = "sessiongraph";
20
+ /** One-line discovery copy the UI shows in its command list. */
21
+ export declare const COMMAND_DESCRIPTION = "\u67E5\u770B\u6216\u91CD\u5EFA\u672C\u673A\u4F1A\u8BDD\u5386\u53F2\u7D22\u5F15\uFF0C\u6216\u76F4\u63A5\u68C0\u7D22\u8FC7\u53BB\u804A\u8FC7\u4EC0\u4E48";
22
+ /** Input syntax the UI shows after the command name. */
23
+ export declare const COMMAND_INPUT_HINT = "[status|index [--force]|search <query>|workspaces]";
24
+ /** The usage line every rejection repeats, so one screen never offers half an answer. */
25
+ export declare const COMMAND_USAGE = "\u7528\u6CD5\uFF1A/sessiongraph [status|index [--force]|search <query>|workspaces]";
26
+ /** Longest snippet the human-facing `search` prints per hit; a terminal is not a transcript. */
27
+ export declare const COMMAND_MAX_SNIPPET_CHARS = 200;
28
+ /**
29
+ * Branded identity of this definition, mirroring `CommandDefinitionId` from
30
+ * `@deepseek-ai/dsh-commands` without importing it. The registry treats the value as an opaque
31
+ * string; the local brand only keeps a plain string from standing in for a stable identity.
32
+ */
33
+ export type CommandDefinitionId = string & {
34
+ readonly __commandDefinitionId: 'CommandDefinitionId';
35
+ };
36
+ /** Stable identity, independent of the display name or the UI's per-run id. */
37
+ export declare const COMMAND_DEFINITION_ID: CommandDefinitionId;
38
+ /** What a handler settles with; mirrors the registry's `success | error` result. */
39
+ export type CommandResult = {
40
+ readonly kind: 'success';
41
+ readonly text?: string;
42
+ } | {
43
+ readonly kind: 'error';
44
+ readonly text: string;
45
+ };
46
+ /**
47
+ * The invocation face a command handler receives, declared structurally — only the members this
48
+ * command reads. The workspace comes from here, never from the process working directory: the
49
+ * command runs in a UI that may be driving several sessions.
50
+ */
51
+ export interface CommandInvocation {
52
+ /** Everything after the command name, including its leading separator whitespace. */
53
+ readonly rawInput: string;
54
+ /** The receiving agent: its id owns any background job, its session carries the workspace. */
55
+ readonly agent?: {
56
+ readonly id?: string;
57
+ readonly session?: {
58
+ readonly header?: {
59
+ readonly cwd?: unknown;
60
+ };
61
+ };
62
+ /**
63
+ * Hand one message to this session's model-facing context. Absent on compositions with no agent
64
+ * loop (an ACP transport, an older host), which is why every use is optional and contained.
65
+ */
66
+ readonly inject?: (message: UserMessage) => void;
67
+ };
68
+ /** UI request cancellation, forwarded to every seam call. */
69
+ readonly signal?: AbortSignal;
70
+ }
71
+ /** One command registration, as the registry's `register` takes it. */
72
+ export interface CommandDefinition {
73
+ /** Stable plugin-namespaced identity. */
74
+ readonly definitionId: CommandDefinitionId;
75
+ /** Lowercase command name, without the slash. */
76
+ readonly name: string;
77
+ /** Discovery copy, shown in the UI's command list. */
78
+ readonly description: string;
79
+ /** Input syntax shown after the name. */
80
+ readonly input: {
81
+ readonly hint: string;
82
+ };
83
+ /**
84
+ * Run the command against the session index.
85
+ * @param invocation - the accepted command line and its context.
86
+ * @returns the settled result the UI renders.
87
+ */
88
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;
89
+ }
90
+ /** The command registry's registration surface, declared structurally. */
91
+ export interface CommandRegistry {
92
+ /**
93
+ * Register one command definition.
94
+ * @param definition - discovery metadata and handler.
95
+ * @returns the disposer unregistering it.
96
+ */
97
+ register(definition: CommandDefinition): () => void;
98
+ }
99
+ /** What the command needs from the plugin configuration. */
100
+ export interface CommandOptions {
101
+ /** Absolute directory holding the index. */
102
+ readonly indexRoot: string;
103
+ /** Longest snippet the `search` subcommand prints per hit. */
104
+ readonly maxSnippetChars: number;
105
+ }
106
+ /** One parsed command line. */
107
+ export type CommandPlan = {
108
+ readonly kind: 'status';
109
+ } | {
110
+ readonly kind: 'workspaces';
111
+ } | {
112
+ readonly kind: 'index';
113
+ readonly force: boolean;
114
+ } | {
115
+ readonly kind: 'search';
116
+ readonly query: string;
117
+ } | {
118
+ readonly kind: 'unknown';
119
+ readonly subcommand: string;
120
+ };
121
+ /**
122
+ * Parse one command line. Pure, so both the dispatcher and its tests agree on what a line means.
123
+ *
124
+ * The registry keeps the separator whitespace in `rawInput`, so a bare `/sessiongraph` arrives as an
125
+ * empty string and must still mean `status`.
126
+ * @param rawInput - everything after the command name, as the UI delivered it.
127
+ * @returns the subcommand and its arguments.
128
+ */
129
+ export declare function parseCommand(rawInput: string): CommandPlan;
130
+ /**
131
+ * Register the `/sessiongraph` command when the host composition provides a command registry.
132
+ * @param ctx - the plugin context (must carry `sessiongraph`; `commands` is optional).
133
+ * @param options - the index root and the snippet cap the subcommands print with.
134
+ */
135
+ export declare function registerCommands(ctx: Context, options: CommandOptions): void;
136
+ /**
137
+ * Parse and dispatch one invocation.
138
+ *
139
+ * A handler that throws makes the registry settle the command as an error from the thrown value, so
140
+ * every expected failure is returned as `kind: 'error'` here; only a defect escapes.
141
+ * @param ctx - the plugin context carrying `sessiongraph`.
142
+ * @param options - the index root and rendering caps.
143
+ * @param invocation - the accepted command line and its context.
144
+ * @returns the settled result.
145
+ */
146
+ export declare function runCommand(ctx: Context, options: CommandOptions, invocation: CommandInvocation): Promise<CommandResult>;
147
+ /** Producer identity an injected notice declares; the session log can attribute it to this plugin. */
148
+ export declare const INJECTED_NOTICE_PLUGIN = "dsh-plugin-sessiongraph";
149
+ /** The session workspace an invocation runs for; `undefined` when the command has none. */
150
+ export declare function invocationWorkspace(invocation: CommandInvocation): string | undefined;
151
+ /**
152
+ * Render a status answer.
153
+ * @param result - the seam's status answer.
154
+ * @param workspace - the session's workspace, shown as context when there is one.
155
+ * @returns the status block.
156
+ */
157
+ export declare function statusText(result: SessiongraphStatusResult, workspace?: string): string;
158
+ /**
159
+ * Render a finished run.
160
+ * @param report - the indexer's report.
161
+ * @returns the two- or three-line summary.
162
+ */
163
+ export declare function indexReportText(report: SessiongraphIndexReport): string;
164
+ /**
165
+ * Render a search answer for a human: title, time, workspace, snippet.
166
+ * @param result - the seam's search answer.
167
+ * @param query - the query text, echoed in the header.
168
+ * @param maxSnippetChars - cap on the snippet printed per hit.
169
+ * @returns the hit list, or the empty-result hint.
170
+ */
171
+ export declare function searchText(result: SessiongraphSearchResult, query: string, maxSnippetChars: number): string;
172
+ /**
173
+ * Render a workspace census.
174
+ * @param result - the seam's workspaces answer.
175
+ * @param current - the session's own workspace, marked with `*` when it appears.
176
+ * @returns the census.
177
+ */
178
+ export declare function workspacesText(result: SessiongraphWorkspacesResult, current?: string): string;
179
+ //# sourceMappingURL=command.d.ts.map
package/lib/command.js ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * The human `/sessiongraph` slash command.
3
+ *
4
+ * The model-facing tools answer the same questions, but a person at the composer should not spend a
5
+ * model turn asking "什么进索引了" or "上次谁提过广告". This command runs directly against
6
+ * `ctx.sessiongraph` in the UI's command plane: nothing it returns becomes a model message or a
7
+ * token.
8
+ *
9
+ * Registered through conditional injection (`ctx.inject(['commands'], …)`) — the command registry is
10
+ * a host composition choice, and its absence must not hide the two tools this plugin also registers.
11
+ * `@deepseek-ai/dsh-commands` is deliberately not imported: the service may be absent, so its public
12
+ * shape is mirrored structurally here, exactly as the codegraph command does.
13
+ * @module dsh-plugin-sessiongraph-tool/command
14
+ */
15
+ import { join } from 'node:path';
16
+ import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
17
+ import { INDEX_RELATIVE_PATH, SessiongraphError } from 'dsh-plugin-sessiongraph-service';
18
+ import { isBackgroundIndexReport } from 'dsh-plugin-sessiongraph-sessions';
19
+ import { formatBytes, formatDuration, formatLiveReplay, formatLiveSessions, formatTime, clipText } from "./render.js";
20
+ import { SESSIONGRAPH_DEFAULT_LIMITS } from "./schema.js";
21
+ /** Command name, without the leading slash. */
22
+ export const COMMAND_NAME = 'sessiongraph';
23
+ /** One-line discovery copy the UI shows in its command list. */
24
+ export const COMMAND_DESCRIPTION = '查看或重建本机会话历史索引,或直接检索过去聊过什么';
25
+ /** Input syntax the UI shows after the command name. */
26
+ export const COMMAND_INPUT_HINT = '[status|index [--force]|search <query>|workspaces]';
27
+ /** The usage line every rejection repeats, so one screen never offers half an answer. */
28
+ export const COMMAND_USAGE = `用法:/${COMMAND_NAME} ${COMMAND_INPUT_HINT}`;
29
+ /** Longest snippet the human-facing `search` prints per hit; a terminal is not a transcript. */
30
+ export const COMMAND_MAX_SNIPPET_CHARS = 200;
31
+ /** Where a subcommand's help ends and the list of what exists begins. */
32
+ const AVAILABLE = `可用子命令:status(索引状态)、index [--force](建/刷新索引)、search <query>(直接检索)、workspaces(工作区列表)`;
33
+ /** Stable identity, independent of the display name or the UI's per-run id. */
34
+ export const COMMAND_DEFINITION_ID = 'dsh-plugin-sessiongraph/command/sessiongraph';
35
+ /**
36
+ * Parse one command line. Pure, so both the dispatcher and its tests agree on what a line means.
37
+ *
38
+ * The registry keeps the separator whitespace in `rawInput`, so a bare `/sessiongraph` arrives as an
39
+ * empty string and must still mean `status`.
40
+ * @param rawInput - everything after the command name, as the UI delivered it.
41
+ * @returns the subcommand and its arguments.
42
+ */
43
+ export function parseCommand(rawInput) {
44
+ const trimmed = rawInput.trim();
45
+ if (trimmed === '')
46
+ return { kind: 'status' };
47
+ const separator = trimmed.search(/\s/u);
48
+ const subcommand = separator === -1 ? trimmed : trimmed.slice(0, separator);
49
+ const rest = separator === -1 ? '' : trimmed.slice(separator).trim();
50
+ switch (subcommand) {
51
+ case 'status':
52
+ return { kind: 'status' };
53
+ case 'workspaces':
54
+ return { kind: 'workspaces' };
55
+ case 'index':
56
+ return { kind: 'index', force: rest.split(/\s+/u).includes('--force') };
57
+ // The query is free text, so it keeps its inner spacing instead of being re-joined from tokens.
58
+ case 'search':
59
+ return { kind: 'search', query: rest };
60
+ default:
61
+ return { kind: 'unknown', subcommand };
62
+ }
63
+ }
64
+ /**
65
+ * Register the `/sessiongraph` command when the host composition provides a command registry.
66
+ * @param ctx - the plugin context (must carry `sessiongraph`; `commands` is optional).
67
+ * @param options - the index root and the snippet cap the subcommands print with.
68
+ */
69
+ export function registerCommands(ctx, options) {
70
+ try {
71
+ ctx.inject(['commands'], child => {
72
+ const commands = child.commands;
73
+ child.effect(() => commands.register({
74
+ definitionId: COMMAND_DEFINITION_ID,
75
+ name: COMMAND_NAME,
76
+ description: COMMAND_DESCRIPTION,
77
+ input: { hint: COMMAND_INPUT_HINT },
78
+ handler: invocation => runCommand(ctx, options, invocation),
79
+ }), 'sessiongraph: slash command');
80
+ });
81
+ }
82
+ catch {
83
+ // Silent on purpose: the command registry is a host composition choice, and a composition
84
+ // without it still gets both model-facing tools. A missing human convenience is not worth a
85
+ // load-time failure, and a warning on every start would only teach people to ignore startup logs.
86
+ }
87
+ }
88
+ /**
89
+ * Parse and dispatch one invocation.
90
+ *
91
+ * A handler that throws makes the registry settle the command as an error from the thrown value, so
92
+ * every expected failure is returned as `kind: 'error'` here; only a defect escapes.
93
+ * @param ctx - the plugin context carrying `sessiongraph`.
94
+ * @param options - the index root and rendering caps.
95
+ * @param invocation - the accepted command line and its context.
96
+ * @returns the settled result.
97
+ */
98
+ export async function runCommand(ctx, options, invocation) {
99
+ const plan = parseCommand(invocation.rawInput);
100
+ try {
101
+ const result = await dispatch(ctx, options, plan, invocation);
102
+ // Only a successful lookup is worth telling the model about: a typo or a failed call is the
103
+ // human's problem, and injecting it would spend context on something the model cannot use.
104
+ if (result.kind === 'success')
105
+ injectCommandNotice(invocation, plan, result.text ?? '');
106
+ return result;
107
+ }
108
+ catch (error) {
109
+ return { kind: 'error', text: describe(error) };
110
+ }
111
+ }
112
+ /**
113
+ * Run one parsed command against the seam.
114
+ * @param ctx - the plugin context carrying `sessiongraph`.
115
+ * @param options - the index root and rendering caps.
116
+ * @param plan - the parsed subcommand.
117
+ * @param invocation - the accepted command line and its context.
118
+ * @returns the settled result the UI renders.
119
+ */
120
+ async function dispatch(ctx, options, plan, invocation) {
121
+ switch (plan.kind) {
122
+ case 'status':
123
+ return statusCommand(ctx, options, invocation);
124
+ case 'workspaces':
125
+ return workspacesCommand(ctx, options, invocation);
126
+ case 'index':
127
+ return indexCommand(ctx, options, plan.force, invocation);
128
+ case 'search':
129
+ return searchCommand(ctx, options, plan.query, invocation);
130
+ case 'unknown':
131
+ return { kind: 'error', text: `未知的子命令 "${plan.subcommand}"。${COMMAND_USAGE}\n${AVAILABLE}` };
132
+ }
133
+ }
134
+ /** Producer identity an injected notice declares; the session log can attribute it to this plugin. */
135
+ export const INJECTED_NOTICE_PLUGIN = 'dsh-plugin-sessiongraph';
136
+ /**
137
+ * Tell the model that a person just ran this command, and what came back.
138
+ *
139
+ * A command's output goes to the UI, not the model — so without this the model would see the next
140
+ * human turn as if the lookup never happened, and re-run the same search. The injected message is a
141
+ * `plugin`-sourced `notice`, never a `user` message: it must not read as something the human typed.
142
+ *
143
+ * Entirely contained. The command has already succeeded and its text already reached the person; a
144
+ * composition without an agent loop (or one whose transport rejects this source) loses only the
145
+ * model-side copy, and that must never turn a successful command into an error.
146
+ * @param invocation - the accepted command line and its context.
147
+ * @param plan - the parsed subcommand that ran.
148
+ * @param text - the result text the person saw.
149
+ */
150
+ function injectCommandNotice(invocation, plan, text) {
151
+ const agent = invocation.agent;
152
+ if (agent?.inject === undefined)
153
+ return;
154
+ try {
155
+ const line = noticeCommandLine(plan);
156
+ agent.inject(createUserMessage({
157
+ content: [{ type: 'text', text: noticeBody(plan, text) }],
158
+ source: {
159
+ kind: 'plugin',
160
+ plugin: INJECTED_NOTICE_PLUGIN,
161
+ form: 'notice',
162
+ // The host bounds a notice summary at CONTEXT_SUMMARY_MAX_CHARS (120); its helper is the one
163
+ // place that bound lives, so this module never invents a truncation of its own.
164
+ summary: boundContextSummary(`用户执行了 ${line}`),
165
+ },
166
+ }));
167
+ }
168
+ catch (error) {
169
+ // The command result already reached the person; the model just does not learn about it.
170
+ console.error(`sessiongraph: could not inject the /${COMMAND_NAME} ${plan.kind} result into the session: ${describe(error)}`);
171
+ }
172
+ }
173
+ /**
174
+ * The command line as a person typed it, for the injected message's first line and summary.
175
+ * @param plan - the parsed subcommand.
176
+ * @returns the rendered command line, including `--force` and the search text when they were given.
177
+ */
178
+ function noticeCommandLine(plan) {
179
+ switch (plan.kind) {
180
+ case 'status':
181
+ return `/${COMMAND_NAME} status`;
182
+ case 'workspaces':
183
+ return `/${COMMAND_NAME} workspaces`;
184
+ case 'index':
185
+ return `/${COMMAND_NAME} index${plan.force ? ' --force' : ''}`;
186
+ case 'search':
187
+ return `/${COMMAND_NAME} search${plan.query === '' ? '' : ` ${plan.query}`}`;
188
+ case 'unknown':
189
+ return `/${COMMAND_NAME} ${plan.subcommand}`;
190
+ }
191
+ }
192
+ /**
193
+ * The model-facing body of one notice: what the person asked, and the result they saw.
194
+ *
195
+ * The result text is the same compact rendering the person got — never a raw dump — and a search
196
+ * carries one closing hint so the model knows it may keep looking through its own tool.
197
+ * @param plan - the parsed subcommand that ran.
198
+ * @param text - the result text the person saw.
199
+ * @returns the message body.
200
+ */
201
+ function noticeBody(plan, text) {
202
+ const line = noticeCommandLine(plan);
203
+ if (plan.kind !== 'search')
204
+ return `用户刚执行了 ${line}。\n\n${text}`;
205
+ return `用户刚执行了 ${line},结果见下面(最多前 10 条)。\n\n${text}\n\n`
206
+ + '如果这些结果不足以回答,可以自己调用 sessiongraph 工具继续检索(search / timeline / session 都可以)。';
207
+ }
208
+ /** The session workspace an invocation runs for; `undefined` when the command has none. */
209
+ export function invocationWorkspace(invocation) {
210
+ const cwd = invocation.agent?.session?.header?.cwd;
211
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : undefined;
212
+ }
213
+ /**
214
+ * Report the index at a glance: size, freshness, and whether live sessions are covered yet.
215
+ * @param ctx - the plugin context carrying `sessiongraph`.
216
+ * @param options - the index root.
217
+ * @param invocation - the accepted command line and its context.
218
+ * @returns one compact status block, or how to build the missing index.
219
+ */
220
+ async function statusCommand(ctx, options, invocation) {
221
+ try {
222
+ const result = await ctx.sessiongraph.query({ operation: 'status', indexRoot: options.indexRoot }, invocation.signal);
223
+ return { kind: 'success', text: statusText(result, invocationWorkspace(invocation)) };
224
+ }
225
+ catch (error) {
226
+ // Unlike the model tool, the command runs with a human watching: "no index yet" is an answer,
227
+ // not a failure, and it comes with the one command that fixes it.
228
+ if (error instanceof SessiongraphError && error.code === 'SESSIONGRAPH_UNAVAILABLE') {
229
+ return { kind: 'success', text: noIndexText(join(options.indexRoot, INDEX_RELATIVE_PATH)) };
230
+ }
231
+ throw error;
232
+ }
233
+ }
234
+ /**
235
+ * Render a status answer.
236
+ * @param result - the seam's status answer.
237
+ * @param workspace - the session's workspace, shown as context when there is one.
238
+ * @returns the status block.
239
+ */
240
+ export function statusText(result, workspace) {
241
+ if (!result.indexed)
242
+ return noIndexText(result.indexPath);
243
+ const built = result.indexedAt === undefined ? '未知' : formatTime(result.indexedAt);
244
+ const lines = [
245
+ `索引:${result.indexPath}`,
246
+ `${result.sessions} 个会话 · ${result.entries} 条可检索条目 · ${result.workspaces} 个工作区 · ${formatBytes(result.bytes)}`,
247
+ `最近一次建索引:${built}`,
248
+ // A store that predates live indexing omits the field; reporting zero is the honest reading,
249
+ // and the line stays so "no live sessions" is never confused with "not supported".
250
+ formatLiveSessions(result.liveSessions ?? 0),
251
+ ];
252
+ if (workspace !== undefined)
253
+ lines.push(`当前工作区:${workspace}`);
254
+ return lines.join('\n');
255
+ }
256
+ /**
257
+ * The one sentence a person sees before an index exists.
258
+ * @param indexPath - where the index would live.
259
+ * @returns the hint naming the command that builds it.
260
+ */
261
+ function noIndexText(indexPath) {
262
+ return `还没有索引(${indexPath})。先跑 /${COMMAND_NAME} index 建一次。`;
263
+ }
264
+ /**
265
+ * Build or refresh the index right now.
266
+ *
267
+ * A human ran this and is willing to wait, so the synchronous report is the normal answer. A store
268
+ * large enough for the host's job registry may still be delegated, in which case the call answers at
269
+ * once with the job id instead of holding the composer open.
270
+ * @param ctx - the plugin context carrying `sessiongraph`.
271
+ * @param options - the index root.
272
+ * @param force - rebuild every session instead of skipping unchanged ones.
273
+ * @param invocation - the accepted command line and its context.
274
+ * @returns the run's summary, or the started job's id.
275
+ */
276
+ async function indexCommand(ctx, options, force, invocation) {
277
+ const owner = invocation.agent?.id;
278
+ const report = await ctx.sessiongraph.index(options.indexRoot, {
279
+ ...(force ? { force: true } : {}),
280
+ // The owning session id is a runtime extension of the seam's options: a job-capable indexer
281
+ // delegates a large run only when the caller names one.
282
+ ...(owner === undefined ? {} : { owner }),
283
+ }, invocation.signal);
284
+ if (isBackgroundIndexReport(report)) {
285
+ return {
286
+ kind: 'success',
287
+ text: `已在后台建索引,jobId=${report.jobId}。稍后用 /${COMMAND_NAME} status 看规模;`
288
+ + `job_output ${report.jobId} 可看这次运行的报告。`,
289
+ };
290
+ }
291
+ return { kind: 'success', text: indexReportText(report) };
292
+ }
293
+ /**
294
+ * Render a finished run.
295
+ * @param report - the indexer's report.
296
+ * @returns the two- or three-line summary.
297
+ */
298
+ export function indexReportText(report) {
299
+ const lines = [
300
+ `建索引完成,用时 ${formatDuration(report.elapsedMs)}:`
301
+ + `新增 ${report.added}、更新 ${report.updated}、跳过 ${report.skipped}、移除 ${report.removed}`
302
+ + `(发现 ${report.discovered} 个会话)。`,
303
+ `${report.sessions} 个会话 · ${report.entries} 条可检索条目 · ${report.workspaces} 个工作区 · ${formatBytes(report.bytes)}`,
304
+ ];
305
+ const replayed = formatLiveReplay(report.liveSessions ?? 0);
306
+ if (replayed !== '')
307
+ lines.push(replayed);
308
+ if (report.failed.length > 0)
309
+ lines.push(`解析失败 ${report.failed.length} 个会话。`);
310
+ return lines.join('\n');
311
+ }
312
+ /**
313
+ * Run one search and print its first page.
314
+ * @param ctx - the plugin context carrying `sessiongraph`.
315
+ * @param options - the index root and snippet cap.
316
+ * @param query - the free-text query.
317
+ * @param invocation - the accepted command line and its context.
318
+ * @returns the hit list, or the empty-result hint.
319
+ */
320
+ async function searchCommand(ctx, options, query, invocation) {
321
+ if (query === '')
322
+ return { kind: 'error', text: `search 需要一个检索词。用法:/${COMMAND_NAME} search <query>` };
323
+ const result = await ctx.sessiongraph.query({
324
+ operation: 'search',
325
+ indexRoot: options.indexRoot,
326
+ query,
327
+ limit: SESSIONGRAPH_DEFAULT_LIMITS.search,
328
+ }, invocation.signal);
329
+ return {
330
+ kind: 'success',
331
+ text: searchText(result, query, Math.min(options.maxSnippetChars, COMMAND_MAX_SNIPPET_CHARS)),
332
+ };
333
+ }
334
+ /**
335
+ * Render a search answer for a human: title, time, workspace, snippet.
336
+ * @param result - the seam's search answer.
337
+ * @param query - the query text, echoed in the header.
338
+ * @param maxSnippetChars - cap on the snippet printed per hit.
339
+ * @returns the hit list, or the empty-result hint.
340
+ */
341
+ export function searchText(result, query, maxSnippetChars) {
342
+ if (result.hits.length === 0) {
343
+ return `没有命中「${query}」。换个词试试;中文两字词即可命中(如「广告」)。`;
344
+ }
345
+ const page = result.truncated ? `(共 ${result.total} 条,只显示前 ${result.hits.length} 条)` : `(共 ${result.total} 条)`;
346
+ const lines = [`命中「${query}」${page}:`];
347
+ for (const hit of result.hits) {
348
+ const title = hit.title === undefined || hit.title.trim() === '' ? '(无标题)' : `《${hit.title}》`;
349
+ lines.push(`${formatTime(hit.time)} · ${title} · ${hit.workspace}`);
350
+ lines.push(` ${clipText(hit.snippet, maxSnippetChars)}`);
351
+ }
352
+ return lines.join('\n');
353
+ }
354
+ /**
355
+ * List the workspaces the index covers, marking the caller's own.
356
+ * @param ctx - the plugin context carrying `sessiongraph`.
357
+ * @param options - the index root.
358
+ * @param invocation - the accepted command line and its context.
359
+ * @returns the workspace list.
360
+ */
361
+ async function workspacesCommand(ctx, options, invocation) {
362
+ const result = await ctx.sessiongraph.query({
363
+ operation: 'workspaces',
364
+ indexRoot: options.indexRoot,
365
+ limit: SESSIONGRAPH_DEFAULT_LIMITS.workspaces,
366
+ }, invocation.signal);
367
+ return { kind: 'success', text: workspacesText(result, invocationWorkspace(invocation)) };
368
+ }
369
+ /**
370
+ * Render a workspace census.
371
+ * @param result - the seam's workspaces answer.
372
+ * @param current - the session's own workspace, marked with `*` when it appears.
373
+ * @returns the census.
374
+ */
375
+ export function workspacesText(result, current) {
376
+ if (result.workspaces.length === 0)
377
+ return `索引里还没有工作区。先跑 /${COMMAND_NAME} index 建一次。`;
378
+ const shown = result.total > result.workspaces.length
379
+ ? `${result.workspaces.length} / ${result.total} 个工作区,最近活跃在前`
380
+ : `${result.workspaces.length} 个工作区,最近活跃在前`;
381
+ const marked = current !== undefined && result.workspaces.some(entry => entry.workspace === current);
382
+ const lines = [marked ? `${shown}(* 为当前工作区):` : `${shown}:`];
383
+ for (const entry of result.workspaces) {
384
+ const marker = entry.workspace === current ? '*' : ' ';
385
+ lines.push(`${marker} ${entry.workspace} · ${entry.sessions} 会话 · ${entry.entries} 条`
386
+ + ` · ${formatTime(entry.firstAt)} → ${formatTime(entry.lastAt)}`);
387
+ }
388
+ return lines.join('\n');
389
+ }
390
+ /**
391
+ * One line describing a thrown value, without a stack.
392
+ * @param error - whatever the seam threw.
393
+ * @returns the message when there is one, else the string form.
394
+ */
395
+ function describe(error) {
396
+ return error instanceof Error ? error.message : String(error);
397
+ }
398
+ //# sourceMappingURL=command.js.map