dsh-session-path 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Compose the clipboard payload for one session.
3
+ *
4
+ * `path` is the bare log-file path; `block` is a paste-ready handoff a user can
5
+ * drop into any other session, and it names the tool that turns the compressed
6
+ * log into something the other agent can simply read.
7
+ *
8
+ * @module dsh-session-path/copy-text
9
+ */
10
+
11
+ /**
12
+ * Build the copied text for one resolved session.
13
+ *
14
+ * @param info - result of `resolveSessionInfo`.
15
+ * @param format - `path` or `block`.
16
+ * @returns the clipboard string.
17
+ */
18
+ export function composeCopyText(info, format) {
19
+ if (format === "path") return info.logPath;
20
+ const lines = [];
21
+ lines.push(`【接续会话】${info.title === "" ? "(无标题)" : info.title}`);
22
+ lines.push(`会话 ID:${info.sessionId}`);
23
+ if (info.cwd !== "") lines.push(`工作目录:${info.cwd}`);
24
+ lines.push(`会话日志:${info.logPath === "" ? "(未找到日志文件)" : info.logPath}`);
25
+ lines.push("");
26
+ lines.push(`提示:日志是 zstd 多帧压缩的 JSONL。在任意 DSH 会话里调用工具 session_handoff({ sessionId: "${info.sessionId}" }) 可直接得到解压后的可读记录路径,再 read 它即可接续。`);
27
+ return lines.join("\n");
28
+ }
package/lib/handoff.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Turn a session log into a readable handoff file.
3
+ *
4
+ * The raw log is a compressed event stream: another agent can find it but cannot
5
+ * `read` it. This module renders the parts a continuation actually needs —
6
+ * user messages, assistant text, and the tool calls between them — and writes
7
+ * either a Markdown transcript or the decompressed JSONL, keeping the **tail**
8
+ * of the session when the character budget bites (the most recent turns are what
9
+ * a continuation must see).
10
+ *
11
+ * @module dsh-session-path/handoff
12
+ */
13
+
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+
17
+ import { readSessionRecords } from "./zstd.js";
18
+ import { dshHome } from "./paths.js";
19
+
20
+ /** Longest tool-call rendering kept per call. */
21
+ const TOOL_ARG_CHARS = 240;
22
+ /** Longest single message kept verbatim. */
23
+ const MESSAGE_CHARS = 20_000;
24
+
25
+ /**
26
+ * Join the text blocks of one message.
27
+ *
28
+ * @param content - message content array.
29
+ * @returns the concatenated text.
30
+ */
31
+ function messageText(content) {
32
+ if (!Array.isArray(content)) return "";
33
+ const parts = [];
34
+ for (const block of content) {
35
+ if (block === null || typeof block !== "object") continue;
36
+ if (block.type !== "text" || typeof block.text !== "string") continue;
37
+ parts.push(block.text);
38
+ }
39
+ return parts.join("\n").trim();
40
+ }
41
+
42
+ /**
43
+ * One short line for a tool call, so the transcript records what was run.
44
+ *
45
+ * @param record - a `tool/call` record.
46
+ * @returns the rendered line.
47
+ */
48
+ function toolLine(record) {
49
+ const name = typeof record.data?.name === "string" ? record.data.name : "tool";
50
+ const raw = typeof record.data?.arguments === "string" ? record.data.arguments : "";
51
+ const args = raw.length > TOOL_ARG_CHARS ? `${raw.slice(0, TOOL_ARG_CHARS)}…` : raw;
52
+ return `- \`${name}\` ${args}`.trimEnd();
53
+ }
54
+
55
+ /**
56
+ * Render one record as a transcript entry, or null when it is not user-visible.
57
+ *
58
+ * @param record - a parsed log record.
59
+ * @returns `{ role, text }` or null.
60
+ */
61
+ function toEntry(record) {
62
+ switch (record.type) {
63
+ case "user/message": {
64
+ const text = messageText(record.data?.content);
65
+ const kind = record.data?.source?.kind;
66
+ if (text === "") return null;
67
+ // Plugin injections are host bookkeeping, not something the user said.
68
+ if (typeof kind === "string" && kind !== "user") return { role: "context", text };
69
+ return { role: "user", text };
70
+ }
71
+ case "assistant/message": {
72
+ const text = messageText(record.data?.message?.content);
73
+ return text === "" ? null : { role: "assistant", text };
74
+ }
75
+ case "tool/call":
76
+ return { role: "tool", text: toolLine(record) };
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** Markdown heading per entry role. */
83
+ const HEADING = { user: "## 用户", assistant: "## 助手", tool: "## 工具调用", context: "## 上下文注入" };
84
+
85
+ /**
86
+ * Render the transcript, keeping the most recent entries within `maxChars`.
87
+ *
88
+ * @param entries - transcript entries in log order.
89
+ * @param maxChars - character budget for the entry bodies.
90
+ * @returns `{ body, kept, truncated }`.
91
+ */
92
+ export function renderEntries(entries, maxChars) {
93
+ const kept = [];
94
+ let used = 0;
95
+ let truncated = false;
96
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
97
+ const entry = entries[index];
98
+ const text = entry.text.length > MESSAGE_CHARS ? `${entry.text.slice(0, MESSAGE_CHARS)}\n…(本条已截断)` : entry.text;
99
+ const size = text.length + HEADING[entry.role].length + 4;
100
+ if (kept.length > 0 && used + size > maxChars) {
101
+ truncated = true;
102
+ break;
103
+ }
104
+ kept.push({ ...entry, text });
105
+ used += size;
106
+ }
107
+ kept.reverse();
108
+ const body = kept.map((entry) => `${HEADING[entry.role]}\n\n${entry.text}`).join("\n\n");
109
+ return { body, kept: kept.length, truncated };
110
+ }
111
+
112
+ /**
113
+ * Markdown handoff: a header block plus the tail of the transcript.
114
+ *
115
+ * @param info - resolved session info.
116
+ * @param entries - transcript entries.
117
+ * @param maxChars - character budget.
118
+ * @returns `{ text, kept, truncated }`.
119
+ */
120
+ export function renderMarkdown(info, entries, maxChars) {
121
+ const { body, kept, truncated } = renderEntries(entries, maxChars);
122
+ const header = [
123
+ "# 会话接续记录",
124
+ "",
125
+ `- 会话 ID:${info.sessionId}`,
126
+ `- 标题:${info.title === "" ? "(无标题)" : info.title}`,
127
+ `- 工作目录:${info.cwd === "" ? "(未知)" : info.cwd}`,
128
+ `- 会话日志:${info.logPath === "" ? "(未找到)" : info.logPath}`,
129
+ `- 导出时间:${new Date().toISOString()}`,
130
+ `- 导出条目:${kept}${truncated ? "(仅保留最近部分,前文已截断)" : ""}`,
131
+ "",
132
+ "---",
133
+ "",
134
+ ];
135
+ return { text: `${header.join("\n")}${body}\n`, kept, truncated };
136
+ }
137
+
138
+ /**
139
+ * JSONL handoff: one header line plus the decompressed records.
140
+ *
141
+ * @param info - resolved session info.
142
+ * @param records - parsed records in log order.
143
+ * @param maxChars - character budget.
144
+ * @returns `{ text, kept, truncated }`.
145
+ */
146
+ export function renderJsonl(info, records, maxChars) {
147
+ const header = {
148
+ type: "session/handoff",
149
+ sessionId: info.sessionId,
150
+ title: info.title,
151
+ cwd: info.cwd,
152
+ logPath: info.logPath,
153
+ exportedAt: new Date().toISOString(),
154
+ };
155
+ const lines = [JSON.stringify(header)];
156
+ let used = lines[0].length;
157
+ let kept = 0;
158
+ for (const record of records) {
159
+ const line = JSON.stringify(record);
160
+ if (used + line.length > maxChars) break;
161
+ lines.push(line);
162
+ used += line.length + 1;
163
+ kept += 1;
164
+ }
165
+ return { text: `${lines.join("\n")}\n`, kept, truncated: kept < records.length };
166
+ }
167
+
168
+ /**
169
+ * Where handoff files live: the configured directory, else `$DSH_HOME/session-handoff`.
170
+ *
171
+ * @param ctx - host plugin context.
172
+ * @param settings - resolved plugin settings.
173
+ * @returns the absolute directory.
174
+ */
175
+ export function handoffDirectory(ctx, settings) {
176
+ return settings.handoffDir !== "" ? settings.handoffDir : join(dshHome(ctx), "session-handoff");
177
+ }
178
+
179
+ /**
180
+ * Build one handoff file for a session and return where it landed.
181
+ *
182
+ * @param ctx - host plugin context.
183
+ * @param settings - resolved plugin settings.
184
+ * @param info - resolved session info.
185
+ * @param options - `{ format?, maxChars? }`.
186
+ * @returns `{ file, chars, kept, truncated, format }`.
187
+ */
188
+ export async function writeHandoff(ctx, settings, info, options = {}) {
189
+ const format = options.format === "jsonl" ? "jsonl" : settings.handoffFormat;
190
+ const maxChars = Number(options.maxChars) > 0 ? Math.floor(Number(options.maxChars)) : settings.maxHandoffChars;
191
+ if (info.logPath === "") throw new Error(`no committed log found for session "${info.sessionId}"`);
192
+ const directory = handoffDirectory(ctx, settings);
193
+ await mkdir(directory, { recursive: true });
194
+ let text;
195
+ let kept;
196
+ let truncated;
197
+ if (format === "jsonl") {
198
+ const records = [];
199
+ for await (const record of readSessionRecords(info.logPath)) records.push(record);
200
+ const rendered = renderJsonl(info, records, maxChars);
201
+ text = rendered.text;
202
+ kept = rendered.kept;
203
+ truncated = rendered.truncated;
204
+ } else {
205
+ const entries = [];
206
+ for await (const record of readSessionRecords(info.logPath)) {
207
+ const entry = toEntry(record);
208
+ if (entry !== null) entries.push(entry);
209
+ }
210
+ const rendered = renderMarkdown(info, entries, maxChars);
211
+ text = rendered.text;
212
+ kept = rendered.kept;
213
+ truncated = rendered.truncated;
214
+ }
215
+ const safeId = info.sessionId.replace(/[^A-Za-z0-9._-]/g, "_");
216
+ const file = join(directory, `${safeId}.${format === "jsonl" ? "jsonl" : "md"}`);
217
+ await writeFile(file, text, "utf8");
218
+ return { file, chars: text.length, kept, truncated, format };
219
+ }
package/lib/index.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * dsh-session-path — host half.
3
+ *
4
+ * Answers one question: *where does this DSH session live on disk, and how can
5
+ * another session read it?* Two tools (`session_path`, `session_handoff`), one
6
+ * read-only HTTP route for the header button, and a short system-prompt guide.
7
+ *
8
+ * The module only wires things together: configuration lives in `./config.js`,
9
+ * path discovery in `./paths.js`, log decoding in `./zstd.js`, rendering in
10
+ * `./handoff.js`, tool contracts in `./tools.js`, the route in `./route.js`.
11
+ *
12
+ * @module dsh-session-path
13
+ */
14
+
15
+ import { defineTool } from "@deepseek-ai/dsh-tools";
16
+
17
+ import { Config, resolveConfig } from "./config.js";
18
+ import { guideText } from "./prompt.js";
19
+ import { registerResolveRoute } from "./route.js";
20
+ import { createToolSpecs } from "./tools.js";
21
+
22
+ export const name = "session-path";
23
+
24
+ export const inject = ["tools", "systemPrompt", "webServer"];
25
+
26
+ export { Config };
27
+
28
+ /**
29
+ * Mount the plugin.
30
+ *
31
+ * @param ctx - host plugin context.
32
+ * @param config - loader config (defaults are re-resolved here).
33
+ */
34
+ export function apply(ctx, config) {
35
+ const settings = resolveConfig(config);
36
+ if (!settings.enabled) return;
37
+
38
+ if (settings.promptEnabled) {
39
+ ctx.systemPrompt.section({
40
+ name: "session-path:guide",
41
+ order: settings.promptOrder,
42
+ text: () => guideText(settings),
43
+ });
44
+ }
45
+
46
+ ctx.effect(() => registerResolveRoute(ctx, settings), "dsh-session-path: resolve route");
47
+
48
+ for (const spec of createToolSpecs({ ctx, settings })) {
49
+ ctx.tools.register(
50
+ defineTool({
51
+ name: spec.name,
52
+ description: spec.description,
53
+ parameters: spec.parameters,
54
+ output: {
55
+ schema: spec.outputSchema,
56
+ render: spec.render,
57
+ },
58
+ ...(spec.presentCall ? { presentCall: spec.presentCall } : {}),
59
+ execute: spec.execute,
60
+ }),
61
+ );
62
+ }
63
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Locate a session's on-disk home.
3
+ *
4
+ * DSH stores one directory per session under `$DSH_HOME/sessions/<project>/`,
5
+ * where `<project>` is derived from the workspace cwd and the session directory
6
+ * is either the bare session id or `session-<id>` (the naming changed across
7
+ * releases). Each session directory holds one committed log per generation:
8
+ * `session[.vN].jsonl[.zstd]`.
9
+ *
10
+ * Both the derived-project fast path and a full scan are provided: the scan is
11
+ * authoritative and never edits anything, it only enumerates directories.
12
+ *
13
+ * @module dsh-session-path/paths
14
+ */
15
+
16
+ import { readdirSync, statSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ /** Canonical committed artifacts only: backups, temp files and aliases are not logs. */
21
+ const LOG_FILE = /^session(?:\.v([1-9]\d*))?\.jsonl(\.zstd)?$/;
22
+
23
+ /**
24
+ * `$DSH_HOME`, resolved from the host service, the environment, then `~/.dsh`.
25
+ *
26
+ * @param ctx - host plugin context (may be a bare object in tests).
27
+ * @returns the absolute DSH home path.
28
+ */
29
+ export function dshHome(ctx) {
30
+ const service = ctx?.get?.("dshHomePath");
31
+ if (typeof service === "function") {
32
+ try {
33
+ const resolved = service();
34
+ if (typeof resolved === "string" && resolved.length > 0) return resolved;
35
+ } catch {
36
+ /* fall through to the environment */
37
+ }
38
+ }
39
+ const fromEnv = process.env.DSH_HOME;
40
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
41
+ return join(homedir(), ".dsh");
42
+ }
43
+
44
+ /**
45
+ * The sessions root: `$DSH_HOME/sessions`.
46
+ *
47
+ * @param ctx - host plugin context.
48
+ * @returns the absolute sessions root.
49
+ */
50
+ export function sessionsRoot(ctx) {
51
+ return join(dshHome(ctx), "sessions");
52
+ }
53
+
54
+ /**
55
+ * Parse a log file name into its generation, or null when it is not a log.
56
+ *
57
+ * @param name - base name of the candidate file.
58
+ * @returns `{ version, compressed }` or null.
59
+ */
60
+ export function logGeneration(name) {
61
+ const match = LOG_FILE.exec(name);
62
+ if (match === null) return null;
63
+ const version = Number(match[1] ?? 0);
64
+ return Number.isSafeInteger(version) ? { version, compressed: Boolean(match[2]) } : null;
65
+ }
66
+
67
+ /**
68
+ * Committed logs of one session directory, newest generation first.
69
+ *
70
+ * Numeric generations order numerically (`v10` beats `v3`), and a compressed
71
+ * artifact wins over a plain one of the same generation.
72
+ *
73
+ * @param directory - session directory.
74
+ * @returns absolute log paths, best candidate first.
75
+ */
76
+ export function listSessionLogs(directory) {
77
+ let names;
78
+ try {
79
+ names = readdirSync(directory);
80
+ } catch {
81
+ return [];
82
+ }
83
+ return names
84
+ .map((name) => ({ name, generation: logGeneration(name) }))
85
+ .filter((entry) => entry.generation !== null)
86
+ .sort((a, b) => b.generation.version - a.generation.version || Number(b.generation.compressed) - Number(a.generation.compressed))
87
+ .map((entry) => join(directory, entry.name));
88
+ }
89
+
90
+ /**
91
+ * The directory name DSH derives from a workspace path.
92
+ *
93
+ * Mirrors the host's project-directory rule: separators become `-`, and the
94
+ * result is fenced by `-` / `--`. This is a fast path only — {@link findSessionDirectory}
95
+ * falls back to a full scan when the guess misses.
96
+ *
97
+ * @param cwd - the session's workspace directory.
98
+ * @returns the derived project directory name.
99
+ */
100
+ export function projectDirectoryName(cwd) {
101
+ return `-${String(cwd).replace(/[\\/]/g, "-")}--`;
102
+ }
103
+
104
+ /**
105
+ * Both spellings a session directory may use, longest-lived first.
106
+ *
107
+ * @param sessionId - the session id as DSH reports it.
108
+ * @returns candidate directory names.
109
+ */
110
+ export function sessionDirectoryNames(sessionId) {
111
+ const id = String(sessionId);
112
+ const bare = id.startsWith("session-") ? id.slice("session-".length) : id;
113
+ return [...new Set([id, bare, `session-${bare}`])];
114
+ }
115
+
116
+ /**
117
+ * Find one session's directory under the sessions root.
118
+ *
119
+ * @param root - sessions root.
120
+ * @param sessionId - session id (with or without the `session-` prefix).
121
+ * @param cwd - optional workspace directory used for the derived fast path.
122
+ * @returns `{ dir, project, logs }`, or null when nothing matches.
123
+ */
124
+ export function findSessionDirectory(root, sessionId, cwd) {
125
+ const names = sessionDirectoryNames(sessionId);
126
+ const projects = [];
127
+ if (typeof cwd === "string" && cwd.length > 0) projects.push(projectDirectoryName(cwd));
128
+ let candidates = [];
129
+ try {
130
+ candidates = readdirSync(root);
131
+ } catch {
132
+ return null;
133
+ }
134
+ for (const project of candidates) {
135
+ if (!projects.includes(project)) projects.push(project);
136
+ }
137
+ let emptyMatch = null;
138
+ for (const project of projects) {
139
+ for (const name of names) {
140
+ const dir = join(root, project, name);
141
+ let stats;
142
+ try {
143
+ stats = statSync(dir);
144
+ } catch {
145
+ continue;
146
+ }
147
+ if (!stats.isDirectory()) continue;
148
+ const logs = listSessionLogs(dir);
149
+ if (logs.length > 0) return { dir, project, logs };
150
+ emptyMatch ??= { dir, project, logs: [] };
151
+ }
152
+ }
153
+ return emptyMatch;
154
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The system-prompt contribution.
3
+ *
4
+ * Kept short on purpose: it only has to tell the model that a session's context
5
+ * is reachable from disk and which tool to use, so that "接着另一个会话继续"
6
+ * becomes a two-step operation instead of a dead end.
7
+ *
8
+ * @module dsh-session-path/prompt
9
+ */
10
+
11
+ /**
12
+ * Guide text for the current settings.
13
+ *
14
+ * @param settings - resolved plugin settings.
15
+ * @returns the prompt section body.
16
+ */
17
+ export function guideText(settings) {
18
+ return [
19
+ "## 会话路径与接续(dsh-session-path)",
20
+ "",
21
+ "- 每个 DSH 会话的原始日志在 `$DSH_HOME/sessions/<工作区>/<会话目录>/session[.vN].jsonl[.zstd]`;`.zstd` 是多帧 Zstandard 容器,直接 read 会失败。",
22
+ "- 要接着另一个会话继续干活:先调用 `session_handoff`(传 `sessionId`,省略则取当前会话)拿到解压后的可读文件路径,再用 read 读取它。",
23
+ "- 只是想知道/复制会话路径时用 `session_path`(只读,不落盘)。",
24
+ `- 交接文件默认写到 \`${settings.handoffDir === "" ? "$DSH_HOME/session-handoff" : settings.handoffDir}\`,格式 \`${settings.handoffFormat}\`,字符预算 ${settings.maxHandoffChars}(保留最近的部分)。`,
25
+ ].join("\n");
26
+ }
package/lib/route.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one HTTP route the browser half needs: resolve a session id into its log
3
+ * path and a paste-ready copy payload.
4
+ *
5
+ * Registration rides the injected fiber, so unloading the plugin withdraws the
6
+ * route. The route is read-only — it never writes a handoff file (that is the
7
+ * `session_handoff` tool's job, so file creation stays a deliberate agent act).
8
+ *
9
+ * @module dsh-session-path/route
10
+ */
11
+
12
+ import { composeCopyText } from "./copy-text.js";
13
+ import { resolveSessionInfo } from "./session-info.js";
14
+ import { isTrustedRequest } from "./trust.js";
15
+
16
+ /** Exact path served by this plugin. */
17
+ export const RESOLVE_ROUTE = "/api/dsh-session-path/resolve";
18
+
19
+ /**
20
+ * Write one JSON envelope.
21
+ *
22
+ * @param res - HTTP response.
23
+ * @param status - status code.
24
+ * @param body - JSON-serialisable body.
25
+ */
26
+ function writeJson(res, status, body) {
27
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
28
+ res.end(JSON.stringify(body));
29
+ }
30
+
31
+ /**
32
+ * Register the resolve route.
33
+ *
34
+ * @param ctx - host plugin context.
35
+ * @param settings - resolved plugin settings.
36
+ * @returns a disposer (no-op when the host has no web server).
37
+ */
38
+ export function registerResolveRoute(ctx, settings) {
39
+ const server = ctx.get("webServer");
40
+ if (server === undefined || typeof server.register !== "function") return () => {};
41
+ const trustedHosts = ctx.get("webRuntime")?.trustedHosts ?? [];
42
+ return server.register({
43
+ kind: "exact",
44
+ path: RESOLVE_ROUTE,
45
+ handler: async (req, res) => {
46
+ if (!isTrustedRequest(req, trustedHosts)) {
47
+ writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } });
48
+ return;
49
+ }
50
+ let params;
51
+ try {
52
+ params = new URL(req.url ?? RESOLVE_ROUTE, "http://localhost").searchParams;
53
+ } catch {
54
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "malformed request url" } });
55
+ return;
56
+ }
57
+ const sessionId = params.get("sessionId") ?? "";
58
+ if (sessionId === "") {
59
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "sessionId is required" } });
60
+ return;
61
+ }
62
+ try {
63
+ const info = await resolveSessionInfo(ctx, settings, sessionId, {
64
+ title: params.get("title") ?? "",
65
+ cwd: params.get("cwd") ?? "",
66
+ });
67
+ writeJson(res, 200, {
68
+ ok: true,
69
+ value: {
70
+ sessionId: info.sessionId,
71
+ title: info.title,
72
+ cwd: info.cwd,
73
+ logPath: info.logPath,
74
+ dir: info.dir,
75
+ exists: info.exists,
76
+ copyFormat: settings.copyFormat,
77
+ text: composeCopyText(info, settings.copyFormat),
78
+ },
79
+ });
80
+ } catch (error) {
81
+ writeJson(res, 500, {
82
+ ok: false,
83
+ error: { code: "internal", message: error instanceof Error ? error.message : String(error) },
84
+ });
85
+ }
86
+ },
87
+ });
88
+ }