@morlay/dsh-reference 0.0.1-alpha.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.
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Reference Expansion",
4
+ "description": "Expands `@path` / `skill:name` references in a user message into injected content at pre-step."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "引用展开",
4
+ "description": "把用户消息里的 `@path` / `skill:name` 引用在 pre-step 展开成注入内容。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@morlay/dsh-reference",
3
+ "version": "0.0.1-alpha.2",
4
+ "description": "Reference expansion for dsh: `@path` / `skill:name` in a user message expand into injected content at pre-step. Independent of the injection channel; the reference parser it reuses is inlined into the artifact, so this package ships without a client-side dependency.",
5
+ "keywords": [
6
+ "context",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "reference"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/morlay/dsh-plugin.git"
15
+ },
16
+ "files": [
17
+ "locale/*.json",
18
+ "dist",
19
+ "src",
20
+ "!**/__tests__"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": "./dist/index.mjs",
25
+ "./package.json": "./package.json",
26
+ "./locale/*.json": "./locale/*.json"
27
+ },
28
+ "dependencies": {
29
+ "mdast-util-from-markdown": "^2.0.2"
30
+ },
31
+ "devDependencies": {
32
+ "@deepseek-ai/cordis": "4.0.4",
33
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.2",
34
+ "@deepseek-ai/dsh-fs": "0.1.7-alpha.2",
35
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
36
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.2",
37
+ "@deepseek-ai/dsh-skill": "0.1.7-alpha.2",
38
+ "@morlay/dsh-client-ui-primitives": "0.0.2-alpha.7",
39
+ "@types/mdast": "^4.0.4"
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "4.0.4",
43
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.2",
44
+ "@deepseek-ai/dsh-fs": "0.1.7-alpha.2",
45
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
46
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.2",
47
+ "@deepseek-ai/dsh-skill": "0.1.7-alpha.2"
48
+ },
49
+ "scripts": {
50
+ "build": "pnpm exec tsdown"
51
+ }
52
+ }
@@ -0,0 +1,144 @@
1
+ import type { FileSystem } from "@deepseek-ai/dsh-fs";
2
+ import type { FileReference } from "./links.ts";
3
+
4
+ // 与 `@deepseek-ai/dsh-tool-fs` 的 read 默认一致:同一份文件在 read 与注入两条路上看到同一个窗口。
5
+ const READ_LIMIT = 2000;
6
+
7
+ const READ_MAX_LINE_LENGTH = 2000;
8
+
9
+ const READ_MAX_BYTES = 50 * 1024;
10
+
11
+ const STREAM_MIN_SIZE = 10 * 1024 * 1024;
12
+
13
+ interface Line {
14
+ readonly number: number;
15
+ readonly text: string;
16
+ }
17
+
18
+ interface Window {
19
+ readonly lines: readonly Line[];
20
+ readonly totalLines: number;
21
+ readonly truncatedByBytes: boolean;
22
+ }
23
+
24
+ export interface FileReadRequest {
25
+ readonly cwd: string | undefined;
26
+ readonly signal: AbortSignal;
27
+ }
28
+
29
+ // 读不出「存在且是普通文本文件」就返回 undefined:引用保持普通文本,本步照常进行。
30
+ export async function readFileContent(
31
+ fs: FileSystem,
32
+ reference: FileReference,
33
+ request: FileReadRequest,
34
+ ): Promise<string | undefined> {
35
+ const offset = reference.lineStart ?? 1;
36
+ const limit =
37
+ reference.lineEnd === undefined ? READ_LIMIT : Math.max(1, reference.lineEnd - offset + 1);
38
+ try {
39
+ const target = await fs.resolve(reference.path, {
40
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
41
+ signal: request.signal,
42
+ });
43
+ const info = await fs.stat(target, request.signal);
44
+ if (info === undefined || info.type !== "file") return undefined;
45
+ const chunks =
46
+ info.size === undefined || info.size >= STREAM_MIN_SIZE
47
+ ? await fs.streamText(target, request.signal)
48
+ : [await fs.readText(target, request.signal)];
49
+ const window = await buildWindow(chunks, { offset, limit });
50
+ if (window === undefined) return undefined;
51
+ return formatFileContent(target.displayPath, {
52
+ offset,
53
+ lines: window.lines,
54
+ totalLines: window.totalLines,
55
+ truncatedByBytes: window.truncatedByBytes,
56
+ });
57
+ } catch {
58
+ request.signal.throwIfAborted();
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ // 与 read 的窗口语义同构:扫到文件末尾拿准确总行数,只保留窗口内的行,字节/单行上限到顶就停。
64
+ async function buildWindow(
65
+ chunks: AsyncIterable<string> | Iterable<string>,
66
+ window: { readonly offset: number; readonly limit: number },
67
+ ): Promise<Window | undefined> {
68
+ const lines: Line[] = [];
69
+ let totalLines = 0;
70
+ let outputBytes = 0;
71
+ let truncatedByBytes = false;
72
+ let lineBuffer = "";
73
+ const lineBufferCap = READ_MAX_LINE_LENGTH + 1;
74
+
75
+ const append = (segment: string): void => {
76
+ if (lineBuffer.length >= lineBufferCap) return;
77
+ lineBuffer += segment;
78
+ if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap);
79
+ };
80
+
81
+ const flush = (): void => {
82
+ const raw = lineBuffer.endsWith("\r") ? lineBuffer.slice(0, -1) : lineBuffer;
83
+ lineBuffer = "";
84
+ totalLines += 1;
85
+ if (truncatedByBytes || totalLines < window.offset || lines.length >= window.limit) return;
86
+ const text =
87
+ raw.length > READ_MAX_LINE_LENGTH
88
+ ? `${raw.slice(0, READ_MAX_LINE_LENGTH)}... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
89
+ : raw;
90
+ const bytes = Buffer.byteLength(text, "utf8") + (lines.length > 0 ? 1 : 0);
91
+ if (outputBytes + bytes > READ_MAX_BYTES) {
92
+ truncatedByBytes = true;
93
+ return;
94
+ }
95
+ outputBytes += bytes;
96
+ lines.push({ number: totalLines, text });
97
+ };
98
+
99
+ for await (const chunk of chunks) {
100
+ let start = 0;
101
+ let newline = chunk.indexOf("\n", start);
102
+ while (newline !== -1) {
103
+ append(chunk.slice(start, newline));
104
+ flush();
105
+ start = newline + 1;
106
+ newline = chunk.indexOf("\n", start);
107
+ }
108
+ append(chunk.slice(start));
109
+ }
110
+ if (lineBuffer.length > 0) flush();
111
+
112
+ const beyondEof =
113
+ !truncatedByBytes && window.offset > totalLines && !(totalLines === 0 && window.offset === 1);
114
+ if (beyondEof) return undefined;
115
+ return { lines, totalLines, truncatedByBytes };
116
+ }
117
+
118
+ // 本步引用的文件内容:内容块形态(见上下文注入规则),行窗口信息与续读提示在信封内。
119
+ function formatFileContent(
120
+ displayPath: string,
121
+ outcome: {
122
+ readonly offset: number;
123
+ readonly lines: readonly Line[];
124
+ readonly totalLines: number;
125
+ readonly truncatedByBytes: boolean;
126
+ },
127
+ ): string {
128
+ const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1);
129
+ let footer: string;
130
+ if (outcome.truncatedByBytes) {
131
+ footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`;
132
+ } else if (endLine < outcome.totalLines) {
133
+ footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`;
134
+ } else {
135
+ footer = `(End of file - total ${outcome.totalLines} lines)`;
136
+ }
137
+ const body =
138
+ outcome.lines.length === 0
139
+ ? footer
140
+ : `${outcome.lines.map((line) => `${line.number}: ${line.text}`).join("\n")}\n\n${footer}`;
141
+ return `<file_content path="${displayPath.replaceAll('"', "&quot;")}">
142
+ ${body}
143
+ </file_content>`;
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { PreStepDecision } from "@deepseek-ai/dsh-agent";
3
+ import type {} from "@deepseek-ai/dsh-fs";
4
+ import { createUserMessage, type ContentBlock } from "@deepseek-ai/dsh-llm";
5
+ import type { UserMessage } from "@deepseek-ai/dsh-session";
6
+ import {
7
+ isUserInvocable,
8
+ renderSkillContent,
9
+ type SkillInvocationSource,
10
+ } from "@deepseek-ai/dsh-skill";
11
+ import { readFileContent } from "./file-content.ts";
12
+ import { fileReferencesIn, skillNamesIn, type FileReference } from "./links.ts";
13
+
14
+ export const name = "reference";
15
+
16
+ export const inject = ["skills"];
17
+
18
+ // 用户显式引用的文件内容注入:`references` 按注入顺序列出这条消息里实际装了的引用(含行窗口)。
19
+ export interface FileReferenceSource {
20
+ readonly kind: "file-reference";
21
+ readonly references: readonly FileReference[];
22
+ }
23
+
24
+ declare module "@deepseek-ai/dsh-llm" {
25
+ interface MessageSourceMap {
26
+ "file-reference": FileReferenceSource;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 内容之外的一条说明(见层级设计「内容之外的说明」):不说来源,模型会再 read 一遍同一段内容。
32
+ * 与内容同处一条消息,所以同一步有多个引用时它也只说一次。
33
+ */
34
+ const NOTE = [
35
+ "<system-reminder>",
36
+ "这些文件内容是按你消息里的 @ 引用刚读取的,直接用它即可,不必再 read 一遍。",
37
+ "</system-reminder>",
38
+ ].join("\n");
39
+
40
+ export function apply(ctx: Context): void {
41
+ ctx.on("agent/pre-step", async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
42
+ const decision = await next();
43
+ if (decision.kind === "reject") return decision;
44
+ // 依赖关系:没有对应工具时,注入也没有意义(模型拿到了内容也用不上那条路径)——
45
+ // 文件引用要有 read 能力,skill 引用要有 skill 工具。是否注入跟着工具走。
46
+ // 工具服务是可选的(`ctx.get` 在未声明 inject 的 ctx 上会抛,所以这里自己兜住):
47
+ // 没有它时不参与判断,按解析结果照常展开。
48
+ const tools = ((): unknown => {
49
+ try {
50
+ return ctx.get("tools");
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ })() as { get(name: string, agent: unknown): unknown } | undefined;
55
+ const hasTool = (name: string): boolean =>
56
+ tools === undefined || tools.get(name, agent) !== undefined;
57
+ const hasRead = hasTool("read");
58
+ const hasSkill = hasTool("skill");
59
+ const names = hasSkill ? skillNamesIn(messages) : [];
60
+ const files = hasRead ? fileReferencesIn(messages) : [];
61
+ if (names.length === 0 && files.length === 0) return decision;
62
+ signal.throwIfAborted();
63
+ const cwd = agent.session.header.cwd;
64
+ const injections: UserMessage[] = [];
65
+ for (const name of names) {
66
+ const skill = await ctx.skills.get(name, { cwd, signal, scope: agent });
67
+ signal.throwIfAborted();
68
+
69
+ if (skill === undefined || !isUserInvocable(skill)) continue;
70
+ const source: SkillInvocationSource = {
71
+ kind: "skill-invocation",
72
+ name,
73
+ form: "instructions",
74
+ };
75
+ injections.push(
76
+ createUserMessage({
77
+ content: [{ type: "text", text: renderSkillContent(skill) }],
78
+ source,
79
+ }),
80
+ );
81
+ }
82
+ // `ctx.fs` 是可选能力:没有文件系统的部署里 skill 注入照常,文件引用保持普通文本。
83
+ const fs = ctx.get("fs");
84
+ const read: FileReference[] = [];
85
+ const envelopes: ContentBlock[] = [];
86
+ for (const reference of files) {
87
+ const envelope =
88
+ fs === undefined ? undefined : await readFileContent(fs, reference, { cwd, signal });
89
+ signal.throwIfAborted();
90
+ if (envelope === undefined) continue;
91
+ read.push(reference);
92
+ envelopes.push({ type: "text", text: envelope });
93
+ }
94
+ if (read.length > 0) {
95
+ injections.push(
96
+ createUserMessage({
97
+ content: [{ type: "text", text: NOTE }, ...envelopes],
98
+ source: { kind: "file-reference", references: read },
99
+ }),
100
+ );
101
+ }
102
+ if (injections.length === 0) return decision;
103
+ return { ...decision, messages: [...decision.messages, ...injections] };
104
+ });
105
+ }
package/src/links.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { UserMessage } from "@deepseek-ai/dsh-session";
2
+ import { findReferences, parseReferenceToken } from "@morlay/dsh-client-ui-primitives";
3
+ import { fromMarkdown } from "mdast-util-from-markdown";
4
+
5
+ const SKILL_PROTOCOL = "skill";
6
+
7
+ const FILE_PROTOCOL = "file";
8
+
9
+ const AT_PREFIX = "@";
10
+
11
+ interface MarkdownNode {
12
+ readonly type: string;
13
+ readonly value?: string;
14
+ readonly children?: readonly MarkdownNode[];
15
+ }
16
+
17
+ function walk(node: MarkdownNode, visit: (node: MarkdownNode) => void): void {
18
+ visit(node);
19
+ for (const child of node.children ?? []) walk(child, visit);
20
+ }
21
+
22
+ function inlineCodeSkillNames(text: string): string[] {
23
+ const names: string[] = [];
24
+ const root = fromMarkdown(text) as unknown as MarkdownNode;
25
+ walk(root, (node) => {
26
+ if (node.type !== "inlineCode" || node.value === undefined) return;
27
+ const reference = parseReferenceToken(node.value);
28
+ if (reference?.protocol === SKILL_PROTOCOL && reference.path !== undefined) {
29
+ names.push(reference.path);
30
+ }
31
+ });
32
+ return names;
33
+ }
34
+
35
+ // 注入只能由用户自己的手势触发:外部文本伪造不了 `source.kind === 'user'`。
36
+ function forEachTextBlock(messages: readonly UserMessage[], visit: (text: string) => void): void {
37
+ for (const message of messages) {
38
+ if ((message.source as { kind?: unknown }).kind !== "user") continue;
39
+ for (const block of message.content) {
40
+ if (block.type !== "text") continue;
41
+ visit(block.text);
42
+ }
43
+ }
44
+ }
45
+
46
+ export function skillNamesIn(messages: readonly UserMessage[]): string[] {
47
+ const names: string[] = [];
48
+ const push = (name: string | undefined): void => {
49
+ if (name === undefined || name === "" || names.includes(name)) return;
50
+ names.push(name);
51
+ };
52
+ forEachTextBlock(messages, (text) => {
53
+ for (const span of findReferences(text)) {
54
+ if (span.reference.protocol !== SKILL_PROTOCOL) continue;
55
+ push(span.reference.path);
56
+ }
57
+ for (const name of inlineCodeSkillNames(text)) push(name);
58
+ });
59
+ return names;
60
+ }
61
+
62
+ export interface FileReference {
63
+ readonly path: string;
64
+ readonly lineStart?: number;
65
+ readonly lineEnd?: number;
66
+ }
67
+
68
+ // 只认 `@` 起手的路径:`file:x` 与 `[label](x)` 都是普通文本;列在 read 里没有语义,丢弃。
69
+ export function fileReferencesIn(messages: readonly UserMessage[]): FileReference[] {
70
+ const references: FileReference[] = [];
71
+ forEachTextBlock(messages, (text) => {
72
+ for (const span of findReferences(text)) {
73
+ const reference = span.reference;
74
+ if (text[span.start] !== AT_PREFIX || reference.protocol !== FILE_PROTOCOL) continue;
75
+ const path = reference.path;
76
+ if (path === undefined || path === "") continue;
77
+ const lineStart = reference.lineStart;
78
+ const next: FileReference = {
79
+ path,
80
+ ...(lineStart === undefined ? {} : { lineStart }),
81
+ ...(lineStart === undefined || reference.lineEnd === undefined
82
+ ? {}
83
+ : { lineEnd: reference.lineEnd }),
84
+ };
85
+ if (references.some((known) => sameWindow(known, next))) continue;
86
+ references.push(next);
87
+ }
88
+ });
89
+ return references;
90
+ }
91
+
92
+ function sameWindow(left: FileReference, right: FileReference): boolean {
93
+ return (
94
+ left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
95
+ );
96
+ }