@morlay/dsh-context-assembler 0.0.2 → 0.0.3
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/README.md +22 -167
- package/dist/index.d.mts +38 -18
- package/dist/index.mjs +187 -22
- package/dist/rows.d.mts +2 -40
- package/dist/rows.mjs +3 -48
- package/locale/en.json +1 -1
- package/locale/zh.json +2 -2
- package/package.json +4 -29
- package/src/channel.ts +86 -0
- package/src/{assembler/defaults.ts → defaults.ts} +7 -22
- package/src/index.ts +110 -40
- package/src/reminder.ts +62 -0
- package/src/rows.ts +11 -51
- package/src/rules.ts +11 -0
- package/dist/agent-instructions-CIgUVIeo.mjs +0 -237
- package/dist/agent-instructions.d.mts +0 -27
- package/dist/agent-instructions.mjs +0 -2
- package/dist/assembler-CDMIBAi8.mjs +0 -421
- package/dist/assembler.d.mts +0 -2
- package/dist/assembler.mjs +0 -2
- package/dist/index-zhmD45A9.d.mts +0 -213
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
- package/dist/scope.d.mts +0 -41
- package/dist/scope.mjs +0 -102
- package/dist/skill-catalog.d.mts +0 -23
- package/dist/skill-catalog.mjs +0 -206
- package/src/agent-instructions/baseline.ts +0 -75
- package/src/agent-instructions/files.ts +0 -117
- package/src/agent-instructions/index.ts +0 -111
- package/src/agent-instructions/preset-owner.ts +0 -70
- package/src/assembler/channel.ts +0 -309
- package/src/assembler/index.ts +0 -129
- package/src/assembler/reminder.ts +0 -101
- package/src/assembler/rules.ts +0 -14
- package/src/scope/index.ts +0 -150
- package/src/skill-catalog/index.ts +0 -203
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { stat } from "node:fs/promises";
|
|
2
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
-
import { Config as UpstreamConfig } from "@deepseek-ai/dsh-agent-instructions";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 上游 baseline 身份:`@deepseek-ai/dsh-agent-instructions` 用它在会话 surface 上认领"基线已注入"。
|
|
7
|
-
*
|
|
8
|
-
* 为什么我们要算它:接管那面之后,我们注入的条目**沿用上游 kind**(客户端「上下文注入」标签与按 kind
|
|
9
|
-
* 认领的消费方才认得这是工作区指令),但只沿用一半不够——上游的认领判据是
|
|
10
|
-
* `source.kind === 'agent-instructions' && source.baseline === true` 且 `baselineIdentity` 与它自己算出的
|
|
11
|
-
* identity **逐字相等**。少这两样,上游(官方 preset 自己装的那行)就认为基线不存在,于是**再注入一条**:
|
|
12
|
-
* 同会话切到官方 preset 后会同时存在两条 AGENTS.md——一条是这份正文、一条是上游模板("Use them as
|
|
13
|
-
* guidance…"),口径互相矛盾,而模型无法把后一条理解成"作废前一条"(两条来源的幂等键不同)。
|
|
14
|
-
*
|
|
15
|
-
* 身份要与上游逐字对齐,但它的 `resolveConfig` / `workspaceBaselineIdentity` / `findProjectRoot` **不在包
|
|
16
|
-
* 出口面**(发布包 `files` 只有 `lib/index.js` 与声明),所以这里复刻它的形状:默认值取自**导出的
|
|
17
|
-
* `Config` schema**(跟随上游默认值演进),字段名与顺序照它的 `workspaceBaselineIdentity` 抄一份;
|
|
18
|
-
* `baseline.spec.ts` 用上游 `./src/*` 出口的真函数比对,防这份复刻悄悄漂移。
|
|
19
|
-
*
|
|
20
|
-
* 配置基线是 base bundle 给那行的 `maxBytes: 65536`(其余取 schema 默认)——与它同源,身份才相等。
|
|
21
|
-
*/
|
|
22
|
-
const BASE_MAX_BYTES = 65_536;
|
|
23
|
-
|
|
24
|
-
/** 上游那行的身份基线:与 `packages/bundle/base/cordis.patch.yml` 的 `agent-instructions` 行 config 同源。 */
|
|
25
|
-
export const UPSTREAM_BASELINE_CONFIG = { maxBytes: BASE_MAX_BYTES } as const;
|
|
26
|
-
|
|
27
|
-
interface BaselineConfig {
|
|
28
|
-
readonly projectRootMarkers: string[];
|
|
29
|
-
readonly maxBytes: number;
|
|
30
|
-
readonly maxSourceBytes: number;
|
|
31
|
-
readonly instructionFileCandidates: string[];
|
|
32
|
-
readonly localInstructionFileCandidates: string[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** schema 调用会把缺省项补齐,所以这里拿到的是上游 schema 的实际默认值。 */
|
|
36
|
-
function resolveBaselineConfig(): BaselineConfig {
|
|
37
|
-
return UpstreamConfig({ ...UPSTREAM_BASELINE_CONFIG }) as unknown as BaselineConfig;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** 上游 `findProjectRoot` 的语义:向上找第一个带标记的目录,找不到就用 cwd。 */
|
|
41
|
-
async function projectRootOf(cwd: string, markers: readonly string[]): Promise<string> {
|
|
42
|
-
let current = resolve(cwd);
|
|
43
|
-
for (;;) {
|
|
44
|
-
for (const marker of markers) {
|
|
45
|
-
if (await exists(join(current, marker))) return current;
|
|
46
|
-
}
|
|
47
|
-
const parent = dirname(current);
|
|
48
|
-
if (parent === current) return resolve(cwd);
|
|
49
|
-
current = parent;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function exists(path: string): Promise<boolean> {
|
|
54
|
-
try {
|
|
55
|
-
await stat(path);
|
|
56
|
-
return true;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** 一个会话的 baseline 身份;cwd 或项目根变了就是另一份身份(上游语义)。 */
|
|
63
|
-
export async function baselineIdentity(cwd: string): Promise<string> {
|
|
64
|
-
const config = resolveBaselineConfig();
|
|
65
|
-
const projectRoot = await projectRootOf(cwd, config.projectRootMarkers);
|
|
66
|
-
// 字段顺序与上游 `workspaceBaselineIdentity` 一致:它比较的是 JSON 字符串,顺序也参与。
|
|
67
|
-
return JSON.stringify({
|
|
68
|
-
projectRoot: relative(cwd, projectRoot),
|
|
69
|
-
projectRootMarkers: config.projectRootMarkers,
|
|
70
|
-
maxBytes: config.maxBytes,
|
|
71
|
-
maxSourceBytes: config.maxSourceBytes,
|
|
72
|
-
instructionFileCandidates: config.instructionFileCandidates,
|
|
73
|
-
localInstructionFileCandidates: config.localInstructionFileCandidates,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
-
|
|
5
|
-
/** 一条要注入的指令文件:`display` 进 id 与正文,`path` 用来读,`root` 区分同名文件。 */
|
|
6
|
-
export interface InstructionFile {
|
|
7
|
-
/** 该文件所属的根(项目根或 `$DSH_HOME`):规则块 id 靠它区分不同根下的同名文件。 */
|
|
8
|
-
readonly root: string;
|
|
9
|
-
readonly display: string;
|
|
10
|
-
readonly path: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface WorkspaceOptions {
|
|
14
|
-
readonly instructionFileCandidates: readonly string[];
|
|
15
|
-
readonly localInstructionFileCandidates: readonly string[];
|
|
16
|
-
readonly dshHome: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** 从 cwd 逐级向上找项目根(含标记目录的那一级)。 */
|
|
20
|
-
async function projectRoot(cwd: string, marker = ".git"): Promise<string | undefined> {
|
|
21
|
-
let current = resolve(cwd);
|
|
22
|
-
for (;;) {
|
|
23
|
-
const found = await stat(join(current, marker)).then(
|
|
24
|
-
() => true,
|
|
25
|
-
() => false,
|
|
26
|
-
);
|
|
27
|
-
if (found) return current;
|
|
28
|
-
const parent = dirname(current);
|
|
29
|
-
if (parent === current) return undefined;
|
|
30
|
-
current = parent;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function abbreviateHome(path: string): string {
|
|
35
|
-
const home = homedir();
|
|
36
|
-
return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 工作区指令链:用户全局 `$DSH_HOME/AGENTS.md`,再从项目根到 cwd 的每一级目录取
|
|
41
|
-
* 基础文件与本地 overlay。顺序由宽泛到具体——具体的在后面,与"更具体的优先"一致。
|
|
42
|
-
*/
|
|
43
|
-
export async function instructionChain(
|
|
44
|
-
cwd: string,
|
|
45
|
-
options: WorkspaceOptions,
|
|
46
|
-
): Promise<InstructionFile[]> {
|
|
47
|
-
const files: InstructionFile[] = [];
|
|
48
|
-
const global = join(options.dshHome, "AGENTS.md");
|
|
49
|
-
if (await isFile(global))
|
|
50
|
-
files.push({ root: options.dshHome, display: abbreviateHome(global), path: global });
|
|
51
|
-
|
|
52
|
-
const root = (await projectRoot(cwd)) ?? resolve(cwd);
|
|
53
|
-
const levels: string[] = [];
|
|
54
|
-
let current = resolve(cwd);
|
|
55
|
-
for (;;) {
|
|
56
|
-
levels.unshift(current);
|
|
57
|
-
if (current === root) break;
|
|
58
|
-
const parent = dirname(current);
|
|
59
|
-
if (parent === current) break;
|
|
60
|
-
current = parent;
|
|
61
|
-
}
|
|
62
|
-
for (const level of levels) {
|
|
63
|
-
for (const name of [
|
|
64
|
-
...options.instructionFileCandidates,
|
|
65
|
-
...options.localInstructionFileCandidates,
|
|
66
|
-
]) {
|
|
67
|
-
const path = join(level, name);
|
|
68
|
-
if (await isFile(path)) files.push({ root, display: relativeDisplay(root, path), path });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return files;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function relativeDisplay(root: string, path: string): string {
|
|
75
|
-
const rel = path.slice(root.length + 1);
|
|
76
|
-
return rel.length === 0 ? path : rel;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function isFile(path: string): Promise<boolean> {
|
|
80
|
-
const info = await stat(path).then(
|
|
81
|
-
(value) => value,
|
|
82
|
-
() => undefined,
|
|
83
|
-
);
|
|
84
|
-
return info?.isFile() === true;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** 读一个指令文件;按 `mtimeMs:size` 缓存,未变就不重读。 */
|
|
88
|
-
export async function readInstruction(
|
|
89
|
-
file: InstructionFile,
|
|
90
|
-
cache: Map<string, { stamp: string; text: string }>,
|
|
91
|
-
maxBytes: number,
|
|
92
|
-
): Promise<string> {
|
|
93
|
-
const info = await stat(file.path).then(
|
|
94
|
-
(value) => value,
|
|
95
|
-
() => undefined,
|
|
96
|
-
);
|
|
97
|
-
if (info?.isFile() !== true) {
|
|
98
|
-
cache.delete(file.path);
|
|
99
|
-
return "";
|
|
100
|
-
}
|
|
101
|
-
const stamp = `${String(info.mtimeMs)}:${String(info.size)}`;
|
|
102
|
-
const cached = cache.get(file.path);
|
|
103
|
-
if (cached?.stamp === stamp) return cached.text;
|
|
104
|
-
const raw = await readFile(file.path, "utf8").catch(() => "");
|
|
105
|
-
const text = budget(raw, file.display, maxBytes);
|
|
106
|
-
cache.set(file.path, { stamp, text });
|
|
107
|
-
return text;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 单文件超出预算时截断并留一行可见提示(不静默丢内容)。 */
|
|
111
|
-
function budget(text: string, display: string, maxBytes: number): string {
|
|
112
|
-
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text.trimEnd();
|
|
113
|
-
const kept = Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
114
|
-
const lastBreak = kept.lastIndexOf("\n");
|
|
115
|
-
const body = (lastBreak > 0 ? kept.slice(0, lastBreak) : kept).trimEnd();
|
|
116
|
-
return `${body}\n\n(Workspace instruction budget ${String(maxBytes)} bytes: ${display} is truncated.)`;
|
|
117
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import type { Context } from "@deepseek-ai/cordis";
|
|
4
|
-
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
5
|
-
import type {} from "@deepseek-ai/dsh-agent-instructions";
|
|
6
|
-
import z from "@deepseek-ai/schemastery";
|
|
7
|
-
import type {} from "../assembler/index.ts";
|
|
8
|
-
import { instructionChain, readInstruction, type InstructionFile } from "./files.ts";
|
|
9
|
-
import { baselineIdentity } from "./baseline.ts";
|
|
10
|
-
import { presetOwnsInstructions } from "./preset-owner.ts";
|
|
11
|
-
|
|
12
|
-
export const name = "context-agent-instructions";
|
|
13
|
-
|
|
14
|
-
export const inject = ["agents", "contextAssembler"];
|
|
15
|
-
|
|
16
|
-
export interface Config {
|
|
17
|
-
/** 每级目录的基础指令文件名。 */
|
|
18
|
-
instructionFileCandidates?: string[];
|
|
19
|
-
/** 基础文件之后加载的本地 overlay 名。 */
|
|
20
|
-
localInstructionFileCandidates?: string[];
|
|
21
|
-
/** 单个指令文件的渲染上限(字节)。 */
|
|
22
|
-
maxBytes?: number;
|
|
23
|
-
/** 用户全局指令所在目录(默认 `$DSH_HOME` 或 `~/.dsh`)。 */
|
|
24
|
-
dshHome?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const Config: z<Config> = z.object({
|
|
28
|
-
instructionFileCandidates: z.array(z.string()).default(["AGENTS.md"]),
|
|
29
|
-
localInstructionFileCandidates: z.array(z.string()).default(["AGENTS.local.md"]),
|
|
30
|
-
maxBytes: z.number().default(65536),
|
|
31
|
-
dshHome: z.string().default(process.env["DSH_HOME"] ?? ""),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
/** 一个会话的指令链:路径集合与它所属的 cwd。 */
|
|
35
|
-
const chains = new WeakMap<Agent, { cwd: string; files: InstructionFile[] }>();
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 工作区指令:一条文件一个规则块(id 为 `agent-instructions:<根标识>:<文件>`),文件变化时正文变、按 id 覆盖。
|
|
39
|
-
*
|
|
40
|
-
* 与上游 `agent-instructions` 的差别:不做 read/write/edit 的 touch 跟踪(本部署的 `AGENTS.md`
|
|
41
|
-
* 几乎不变),只在每步按 `mtime:size` 对账;一份文件一条 id,于是变化只重发变了的那一份。
|
|
42
|
-
*/
|
|
43
|
-
export function apply(ctx: Context, config: Config): void {
|
|
44
|
-
const options = {
|
|
45
|
-
instructionFileCandidates: config.instructionFileCandidates ?? ["AGENTS.md"],
|
|
46
|
-
localInstructionFileCandidates: config.localInstructionFileCandidates ?? ["AGENTS.local.md"],
|
|
47
|
-
dshHome:
|
|
48
|
-
config.dshHome === undefined || config.dshHome === "" ? defaultDshHome() : config.dshHome,
|
|
49
|
-
};
|
|
50
|
-
const maxBytes = config.maxBytes ?? 65536;
|
|
51
|
-
const cache = new Map<string, { stamp: string; text: string }>();
|
|
52
|
-
|
|
53
|
-
const install = async (agent: Agent): Promise<void> => {
|
|
54
|
-
if (chains.has(agent)) return;
|
|
55
|
-
const cwd = agent.session.header.cwd === undefined ? process.cwd() : agent.session.header.cwd;
|
|
56
|
-
const chain = { cwd, files: await instructionChain(cwd, options) };
|
|
57
|
-
chains.set(agent, chain);
|
|
58
|
-
// 上游认领判据要的身份:缺它,上游(官方 preset 自己装的那行)会以为基线不存在,再注入一条自己的模板。
|
|
59
|
-
const identity = await baselineIdentity(cwd);
|
|
60
|
-
for (const file of chain.files) {
|
|
61
|
-
ctx.contextAssembler.registerRule({
|
|
62
|
-
id: `agent-instructions:${rootTag(file.root)}:${file.display}`,
|
|
63
|
-
// 对外身份沿用上游那两样(skill 面相反,用我们自己的 kind):kind 让客户端标签与按 kind 认领的消费方认得这是
|
|
64
|
-
// 工作区指令;`baseline` 与 `baselineIdentity` 让上游的认领判据(kind + baseline===true + 身份相等)
|
|
65
|
-
// 认这份条目就是基线,于是它不再注入自己那条模板。`changes` 留空——上游那套按文件做 reconciliation
|
|
66
|
-
// 的记录我们不做(一条文件一条 id)。
|
|
67
|
-
source: () => ({
|
|
68
|
-
kind: "agent-instructions",
|
|
69
|
-
form: "instructions",
|
|
70
|
-
baseline: true,
|
|
71
|
-
baselineIdentity: identity,
|
|
72
|
-
changes: [],
|
|
73
|
-
}),
|
|
74
|
-
text: async (target) => {
|
|
75
|
-
// 这个会话的 preset 自己装了这一面(官方 `standard` / `ptc` / `cordis` 都装了上游那行)就让位:
|
|
76
|
-
// 同一份工作区指令只从一处到模型(见 [`preset-owner.ts`](./preset-owner.ts))。
|
|
77
|
-
if (await presetOwnsInstructions(ctx, target)) return "";
|
|
78
|
-
const current = chains.get(target);
|
|
79
|
-
if (current === undefined || !current.files.some((entry) => entry.path === file.path))
|
|
80
|
-
return "";
|
|
81
|
-
return readInstruction(file, cache, maxBytes);
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
ctx.on("agent/created", ({ agent }) => {
|
|
88
|
-
void install(agent).catch((error: unknown) => {
|
|
89
|
-
ctx.logger.warn(`context-agent-instructions: ${String(error)}`);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
for (const agent of ctx.agents.list()) {
|
|
93
|
-
void install(agent).catch((error: unknown) => {
|
|
94
|
-
ctx.logger.warn(`context-agent-instructions: ${String(error)}`);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function defaultDshHome(): string {
|
|
100
|
-
const home = process.env["HOME"] ?? "~";
|
|
101
|
-
return resolve(process.env["DSH_HOME"] ?? `${home}/.dsh`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* 规则块 id 里的根标识:规则声明是**全局按 id 覆盖**的,而 `display` 只是根内相对路径——
|
|
106
|
-
* 同进程里两个项目根都有 `AGENTS.md` 时,不带根标识的 id 会互相顶掉(后注册者覆盖前者,
|
|
107
|
-
* 前者此后读到空正文)。8 位摘要够唯一,又不把绝对路径塞进提示词 id。
|
|
108
|
-
*/
|
|
109
|
-
function rootTag(root: string): string {
|
|
110
|
-
return createHash("sha256").update(root).digest("hex").slice(0, 8);
|
|
111
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「这个会话的 preset 行清单里装了上游 `agent-instructions` 吗」——装了就让位,这一面归 preset。
|
|
3
|
-
*
|
|
4
|
-
* 为什么要有这条判据:官方 web-app 把 `agent-instructions` 从 host 平面设成 `disabled`、交给 preset 的行清单
|
|
5
|
-
* ([`web-app/cordis.patch.yml:546`](../../../vendor/deepseek-harness/packages/bundle/web-app/cordis.patch.yml) 与
|
|
6
|
-
* `presets/*.patch.yml`)。本部署保留官方 preset 整套,于是**同一个会话**里 preset 平面的上游行与 host 平面的
|
|
7
|
-
* 我们都会注入:一步里两条 AGENTS.md,而模型无法把后一条理解成"作废前一条"(两条的幂等键不同)。
|
|
8
|
-
*
|
|
9
|
-
* 方向:**我们让位**,不是上游认领我们。上游 `agent-instructions` 的认领判据只看它**看得见**的东西
|
|
10
|
-
* (本步 claimed 的消息 + 会话 surface:
|
|
11
|
-
* [`agent-instructions/src/index.ts:46-63`](../../../vendor/deepseek-harness/packages/context/agent-instructions/src/index.ts)),
|
|
12
|
-
* 第 1 步两侧都在各自算注入,谁也算不出对方那一条,所以"由上游认领"救不了首步的重复
|
|
13
|
-
* (`baselineIdentity` 那份身份管的是另一件事,见 [`baseline.ts`](./baseline.ts))。
|
|
14
|
-
*
|
|
15
|
-
* 判据取**行清单里的模块名**而不是行 id:行 id 在 preset 的 `config.plugins` 里是自由的,模块名才是"哪一面的
|
|
16
|
-
* 提供者"。`enabled === true` 表示这一行在这个代际真的装着;`'conditional'`(`!!js` 读不出)按"没装"处理——
|
|
17
|
-
* 读不出就不敢让位,宁可我们提供。
|
|
18
|
-
*/
|
|
19
|
-
import type { Context } from "@deepseek-ai/cordis";
|
|
20
|
-
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
21
|
-
// 只用它的类型:`ctx.agentPresets` 的形状来自上游注册表的声明合并,装不上时这里读到 undefined。
|
|
22
|
-
import type {} from "@deepseek-ai/dsh-agent-preset-registry";
|
|
23
|
-
|
|
24
|
-
/** 上游那一行的模块名:与 `presets/*.patch.yml` 里写的一字不差。 */
|
|
25
|
-
const UPSTREAM_MODULE = "@deepseek-ai/dsh-agent-instructions";
|
|
26
|
-
|
|
27
|
-
/** 会话 → 它当前的 preset。preset 变了(空白窗口里换 preset)就整份重算。 */
|
|
28
|
-
const decided = new WeakMap<Agent, { preset: string; owns: Promise<boolean> }>();
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 该会话的 preset 自己提供了工作区指令这一面吗。
|
|
32
|
-
* @param ctx - 能解析到注册表的上下文(能力自己的 apply ctx)。
|
|
33
|
-
* @param agent - 要判的会话。
|
|
34
|
-
* @returns 行清单里有上游那一行且它是启用的。
|
|
35
|
-
*/
|
|
36
|
-
export function presetOwnsInstructions(ctx: Context, agent: Agent): Promise<boolean> {
|
|
37
|
-
const registry = registryOf(ctx);
|
|
38
|
-
if (registry === undefined) return Promise.resolve(false);
|
|
39
|
-
const preset = registry.composedPreset(agent.ctx);
|
|
40
|
-
// 没挂 preset(子代理还没 join、没装注册表的部署、测试里的假 standing scope):这一面归我们。
|
|
41
|
-
if (preset === undefined) return Promise.resolve(false);
|
|
42
|
-
|
|
43
|
-
const cached = decided.get(agent);
|
|
44
|
-
if (cached?.preset === preset) return cached.owns;
|
|
45
|
-
|
|
46
|
-
const owns = registry
|
|
47
|
-
.compositionInventory()
|
|
48
|
-
.then((inventory) =>
|
|
49
|
-
inventory.some(
|
|
50
|
-
(composition) =>
|
|
51
|
-
composition.id === preset &&
|
|
52
|
-
composition.rows.some(
|
|
53
|
-
(row) => row.moduleName === UPSTREAM_MODULE && row.enabled === true,
|
|
54
|
-
),
|
|
55
|
-
),
|
|
56
|
-
)
|
|
57
|
-
// 读不出来就当我们提供:缺内容比重复更糟,而"读不出来"只可能来自坏掉的注册表。
|
|
58
|
-
.catch(() => false);
|
|
59
|
-
decided.set(agent, { preset, owns });
|
|
60
|
-
return owns;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 注册表可能压根没装(没有 preset 的部署形态);`ctx.get` 在不提供时读到 undefined。 */
|
|
64
|
-
function registryOf(ctx: Context): Context["agentPresets"] | undefined {
|
|
65
|
-
try {
|
|
66
|
-
return ctx.get("agentPresets") as Context["agentPresets"] | undefined;
|
|
67
|
-
} catch {
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
}
|
package/src/assembler/channel.ts
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
2
|
-
import { Service, type Context } from "@deepseek-ai/cordis";
|
|
3
|
-
import type { MessageSource } from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { renderReminder, renderVirtualSkill } from "./reminder.ts";
|
|
5
|
-
|
|
6
|
-
/** 正文到达模型的方式:自动注入,或等模型按需加载。 */
|
|
7
|
-
export type InjectionMode = "auto" | "on-demand";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* 一条按 skill 形态声明的注入:正文是本仓库维护的文案。
|
|
11
|
-
*
|
|
12
|
-
* 正文与 agent 无关(工具集在装配期就定了),所以 skill 在装配期注册一次、全局可见。
|
|
13
|
-
*/
|
|
14
|
-
export interface PromptSkillDeclaration {
|
|
15
|
-
/** kebab-case:skill 名,也是注入条目的 id。 */
|
|
16
|
-
readonly name: string;
|
|
17
|
-
readonly title: string;
|
|
18
|
-
/** 目录行摘要:讲清什么时候该加载它。 */
|
|
19
|
-
readonly description: string;
|
|
20
|
-
/**
|
|
21
|
-
* 正文按**会话**生成:模式可能只给一部分工具,正文要跟着修剪(不该讲用不到的工具)。
|
|
22
|
-
* 不传 agent(装配期注册 skill 时)返回不过滤的完整版。
|
|
23
|
-
*/
|
|
24
|
-
readonly content: (agent?: Agent) => string;
|
|
25
|
-
/**
|
|
26
|
-
* 这个 skill 赖以成立的**入口工具**(任一存在即可,不是全部)。都不可见时它不该出现在该会话的技能
|
|
27
|
-
* 目录里——"不能用 skill 工具就没有技能目录"是同一个道理:技能也跟着工具走。
|
|
28
|
-
*
|
|
29
|
-
* 缺省语义就是"一个都不在就藏起来";声明方要自己挑入口,别把组内所有可能的工具都倒进来
|
|
30
|
-
* (子代理控制行与团队插件会提供同名工具,全集当判据会把不该出现的 skill 留下)。
|
|
31
|
-
*/
|
|
32
|
-
readonly requires?: readonly string[];
|
|
33
|
-
/** 缺省 `on-demand`:只有正文确有必要常驻时才写 `auto`。 */
|
|
34
|
-
readonly injection?: InjectionMode;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface RegisteredSkill {
|
|
38
|
-
readonly digest: string;
|
|
39
|
-
readonly dispose: () => void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface AgentState {
|
|
43
|
-
/** 本步装配降级出来的 section 条目:id → 正文(未渲染信封)。 */
|
|
44
|
-
sections: ReadonlyMap<string, string>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* 一条规则块声明:系统级信息(工作区指令、skill 目录),文本按 id 幂等注入、同 id 覆盖。
|
|
49
|
-
* 文本可以是异步的(skill 目录要读 skill 注册表)。
|
|
50
|
-
*/
|
|
51
|
-
export interface PromptRuleDeclaration {
|
|
52
|
-
readonly id: string;
|
|
53
|
-
readonly text: (agent: Agent) => string | Promise<string>;
|
|
54
|
-
/**
|
|
55
|
-
* 这条规则的对外身份。工作区指令那一面按上游 kind 发消息(skill 面相反,用我们自己的 kind),好让按 kind 认领的
|
|
56
|
-
* 消费方(客户端标签、上游的实验性约束收集)认得出来;不声明就是通道自己的 `context-assembler`。
|
|
57
|
-
* 幂等键仍是 `id`(`reminderMessage` 会把它塞进 source)。
|
|
58
|
-
*/
|
|
59
|
-
readonly source?: (agent: Agent) => MessageSource;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** 一条待注入的条目:正文已渲染好,`source` 是它声明给外部的身份。 */
|
|
63
|
-
export interface PromptEntry {
|
|
64
|
-
readonly text: string;
|
|
65
|
-
readonly source?: MessageSource;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 注入通道:调用方声明「什么内容、怎么到达模型」,这里负责到达方式。
|
|
70
|
-
*
|
|
71
|
-
* - `on-demand`:注册成模型可用 skill(进 skill 目录,正文按需加载);
|
|
72
|
-
* - `auto`:正文自动注入 reminder(注册成 skill 但标 `modelInvocable: false`,因为已经常驻);
|
|
73
|
-
* - `replaceSection` / `suppressSection`:装配结果上的文本改写与丢弃。
|
|
74
|
-
*
|
|
75
|
-
* 覆盖语义(同 id 最新取代更早)由系统提示词里的声明说明一次,见 [`rules.ts`](./rules.ts)。
|
|
76
|
-
*/
|
|
77
|
-
export class ContextAssembler extends Service {
|
|
78
|
-
private readonly declarations = new Map<string, PromptSkillDeclaration>();
|
|
79
|
-
private readonly rules = new Map<string, PromptRuleDeclaration>();
|
|
80
|
-
private readonly overrides = new Map<string, (agent: Agent) => string | undefined>();
|
|
81
|
-
private readonly suppressed = new Set<string>();
|
|
82
|
-
private readonly skills = new Map<string, RegisteredSkill>();
|
|
83
|
-
private readonly states = new WeakMap<Agent, AgentState>();
|
|
84
|
-
/**
|
|
85
|
-
* 不要 instruction 类注入的会话(模式说了 `instructions: false`):常驻正文、规则块与降级 section 都不进。
|
|
86
|
-
* 按需 skill 不受它管(那是模型自己加载),引用材料也不经通道。
|
|
87
|
-
*/
|
|
88
|
-
private readonly withoutInstructions = new WeakSet<Agent>();
|
|
89
|
-
|
|
90
|
-
/** 本会话的模式收窄(白名单):投影层的过滤不碰注册表,注册表上看不出"谁能用"。 */
|
|
91
|
-
private readonly toolScopes = new WeakMap<Agent, (tool: string) => boolean>();
|
|
92
|
-
|
|
93
|
-
/** `host` 是构造期那个根 ctx:`skills` 的访问权限挂在插件的 inject 声明上,不能依赖访问者 ctx。 */
|
|
94
|
-
constructor(private readonly host: Context) {
|
|
95
|
-
super(host, "contextAssembler");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
registerSkill(declaration: PromptSkillDeclaration): () => void {
|
|
99
|
-
this.declarations.set(declaration.name, declaration);
|
|
100
|
-
this.refreshSkills();
|
|
101
|
-
return () => {
|
|
102
|
-
if (this.declarations.get(declaration.name) !== declaration) return;
|
|
103
|
-
this.declarations.delete(declaration.name);
|
|
104
|
-
this.refreshSkills();
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
registerRule(declaration: PromptRuleDeclaration): () => void {
|
|
109
|
-
this.rules.set(declaration.id, declaration);
|
|
110
|
-
return () => {
|
|
111
|
-
if (this.rules.get(declaration.id) === declaration) this.rules.delete(declaration.id);
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
replaceSection(name: string, text: (agent: Agent) => string | undefined): () => void {
|
|
116
|
-
this.overrides.set(name, text);
|
|
117
|
-
return () => {
|
|
118
|
-
if (this.overrides.get(name) === text) this.overrides.delete(name);
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
suppressSection(name: string): () => void {
|
|
123
|
-
this.suppressed.add(name);
|
|
124
|
-
return () => {
|
|
125
|
-
this.suppressed.delete(name);
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
isSuppressed(name: string): boolean {
|
|
130
|
-
return this.suppressed.has(name);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** 装配结果上该 section 要换成的文本;undefined 表示原样保留。 */
|
|
134
|
-
replacement(name: string, agent: Agent | undefined): string | undefined {
|
|
135
|
-
const override = this.overrides.get(name);
|
|
136
|
-
return override === undefined || agent === undefined ? undefined : override(agent);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* 本步要注入的条目:键 → 已渲染好的正文(规则块或内容块)。
|
|
141
|
-
* `sections` 来自本步装配,`rules` 与 `auto` skill 正文在这里现算。
|
|
142
|
-
*/
|
|
143
|
-
/** 这个会话要不要 instruction 类注入(模式说了 `instructions: false` 就是不要,见 {@link withoutInstructions})。 */
|
|
144
|
-
setInstructions(agent: Agent, on: boolean): void {
|
|
145
|
-
if (on) this.withoutInstructions.delete(agent);
|
|
146
|
-
else this.withoutInstructions.add(agent);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* 本会话的**工具收窄**(模式白名单):投影层只过滤装配结果、不碰工具注册表,所以"谁能用"这件事
|
|
151
|
-
* 必须在通道上登记,技能目录与组正文才收得住。与 {@link setInstructions} 同形——谓词由模式那一行给
|
|
152
|
-
* (`context-scope`),通道自己不碰 `tools` 服务。
|
|
153
|
-
*/
|
|
154
|
-
restrictTools(agent: Agent, allowed: (tool: string) => boolean): void {
|
|
155
|
-
this.toolScopes.set(agent, allowed);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* 本会话的工具可见性:调用方给出的注册表可见性 × 已登记的模式收窄。技能目录与组正文共用它,
|
|
160
|
-
* 免得同一套判据在几处各写一遍。
|
|
161
|
-
*/
|
|
162
|
-
visibleTools(agent: Agent, registered: (tool: string) => boolean): (tool: string) => boolean {
|
|
163
|
-
const allowed = this.toolScopes.get(agent);
|
|
164
|
-
return allowed === undefined ? registered : (tool) => registered(tool) && allowed(tool);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* 该会话看不到的 skill 名:声明了 `requires` 而依赖的工具一个都不可见。
|
|
169
|
-
*
|
|
170
|
-
* 目录由 `context-skill-catalog` 渲染,它把工具可见性作为谓词传进来(通道不碰 tools 服务)。
|
|
171
|
-
*/
|
|
172
|
-
hiddenSkills(visible: (tool: string) => boolean): ReadonlySet<string> {
|
|
173
|
-
const hidden = new Set<string>();
|
|
174
|
-
for (const [name, declaration] of this.declarations) {
|
|
175
|
-
const requires = declaration.requires;
|
|
176
|
-
if (requires === undefined || requires.length === 0) continue;
|
|
177
|
-
if (!requires.some(visible)) hidden.add(name);
|
|
178
|
-
}
|
|
179
|
-
return hidden;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 该 skill 按会话修剪后的正文:注册表只能存一份(技能注册是装配期一次、全局可见),所以按需加载
|
|
184
|
-
* 路径(接管的 `skill` 工具)要用这里重新算一次,才和 `auto` 正文一样跟着工具走。
|
|
185
|
-
*/
|
|
186
|
-
contentFor(name: string, agent: Agent | undefined): string | undefined {
|
|
187
|
-
return this.declarations.get(name)?.content(agent);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** 一个 skill 在这个会话能不能被用到:声明了 `requires` 时至少一个工具在模式收窄内。 */
|
|
191
|
-
private reachable(
|
|
192
|
-
declaration: PromptSkillDeclaration,
|
|
193
|
-
scope: ((tool: string) => boolean) | undefined,
|
|
194
|
-
): boolean {
|
|
195
|
-
const requires = declaration.requires;
|
|
196
|
-
if (requires === undefined || requires.length === 0) return true;
|
|
197
|
-
// 没有收窄(没装模式的部署)就是全可见。
|
|
198
|
-
if (scope === undefined) return true;
|
|
199
|
-
return requires.some((tool) => scope(tool));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async collect(agent: Agent): Promise<Map<string, PromptEntry>> {
|
|
203
|
-
const entries = new Map<string, PromptEntry>();
|
|
204
|
-
const scope = this.toolScopes.get(agent);
|
|
205
|
-
// 这一档开关管**一切 instruction 类送达**:常驻正文(内容块)、规则块、降级 section。按需 skill 照常注册
|
|
206
|
-
// (模型自己调 `skill` 才拿到正文,那已不是"注入");用户手打 `@` 引用带进来的材料走
|
|
207
|
-
// `@morlay/dsh-reference` 的 pre-step,不经这里。
|
|
208
|
-
const instructionsOff = this.withoutInstructions.has(agent);
|
|
209
|
-
if (!instructionsOff) {
|
|
210
|
-
for (const declaration of this.declarations.values()) {
|
|
211
|
-
if ((declaration.injection ?? "on-demand") !== "auto") continue;
|
|
212
|
-
// 常驻正文也跟随工具可见性:它声明的 `requires` 工具在这个会话一个都不可用,正文就不该注入
|
|
213
|
-
// (与 skill 目录那侧的 `hiddenSkills` 同一判据;通道不碰 tools 注册表,只用模式收窄那一份)。
|
|
214
|
-
if (!this.reachable(declaration, scope)) continue;
|
|
215
|
-
const body = declaration.content(agent);
|
|
216
|
-
// 常驻送达的 skill 正文与按需加载同一形态:内容块,不是规则块。
|
|
217
|
-
if (body.length > 0)
|
|
218
|
-
entries.set(declaration.name, { text: renderVirtualSkill(declaration.name, body) });
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
for (const declaration of this.rules.values()) {
|
|
222
|
-
if (instructionsOff) break;
|
|
223
|
-
// 注入路径不能被任何一个内容提供者拖死:文本是异步算的(技能目录要读注册表、工作区指令要读文件),
|
|
224
|
-
// 谁卡住都不该让人等在这里——超时或抛错都按"这条没有内容"处理。
|
|
225
|
-
const text = await withTimeout(declaration.text(agent), CONTENT_TIMEOUT_MS);
|
|
226
|
-
if (text.length > 0) {
|
|
227
|
-
entries.set(declaration.id, {
|
|
228
|
-
text: renderReminder(declaration.id, text),
|
|
229
|
-
...(declaration.source === undefined ? {} : { source: declaration.source(agent) }),
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (!instructionsOff) {
|
|
234
|
-
for (const [id, text] of this.sectionsOf(agent)) {
|
|
235
|
-
entries.set(id, { text: renderReminder(id, text) });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return entries;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private sectionsOf(agent: Agent): ReadonlyMap<string, string> {
|
|
242
|
-
return this.states.get(agent)?.sections ?? new Map();
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* 每步装配调一次:记下本步降级出来的 section 文本,供下一步注入。
|
|
247
|
-
*/
|
|
248
|
-
sync(agent: Agent, input: { readonly sections: ReadonlyMap<string, string> }): void {
|
|
249
|
-
this.stateOf(agent).sections = input.sections;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** skill 注册(装配期一次,全局可见):正文没变的不重注册。 */
|
|
253
|
-
private refreshSkills(agent?: Agent): void {
|
|
254
|
-
for (const [name, declaration] of this.declarations) {
|
|
255
|
-
const content = declaration.content(agent);
|
|
256
|
-
const modelInvocable = (declaration.injection ?? "on-demand") === "on-demand";
|
|
257
|
-
const digest = `${modelInvocable ? "model" : "auto"}\u0000${declaration.description}\u0000${content}`;
|
|
258
|
-
const current = this.skills.get(name);
|
|
259
|
-
if (current?.digest === digest) continue;
|
|
260
|
-
current?.dispose();
|
|
261
|
-
const dispose = this.host.skills.register({
|
|
262
|
-
name,
|
|
263
|
-
description: declaration.description,
|
|
264
|
-
content,
|
|
265
|
-
source: "runtime",
|
|
266
|
-
invocation: { modelInvocable, userInvocable: true },
|
|
267
|
-
});
|
|
268
|
-
this.skills.set(name, { digest, dispose });
|
|
269
|
-
}
|
|
270
|
-
for (const [name, registered] of this.skills) {
|
|
271
|
-
if (this.declarations.has(name)) continue;
|
|
272
|
-
registered.dispose();
|
|
273
|
-
this.skills.delete(name);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private stateOf(agent: Agent): AgentState {
|
|
278
|
-
const existing = this.states.get(agent);
|
|
279
|
-
if (existing !== undefined) return existing;
|
|
280
|
-
const state: AgentState = { sections: new Map() };
|
|
281
|
-
this.states.set(agent, state);
|
|
282
|
-
return state;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
declare module "@deepseek-ai/cordis" {
|
|
287
|
-
interface Context {
|
|
288
|
-
contextAssembler: ContextAssembler;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** 一条注入内容的取用上限:超过就按"没有内容"处理,不阻塞这一轮请求。 */
|
|
293
|
-
const CONTENT_TIMEOUT_MS = 2000;
|
|
294
|
-
|
|
295
|
-
async function withTimeout(work: string | Promise<string>, ms: number): Promise<string> {
|
|
296
|
-
if (typeof work === "string") return work;
|
|
297
|
-
const settled = Promise.resolve(work).catch(() => "");
|
|
298
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
299
|
-
const expiry = new Promise<string>((resolve) => {
|
|
300
|
-
timer = setTimeout(() => {
|
|
301
|
-
resolve("");
|
|
302
|
-
}, ms);
|
|
303
|
-
});
|
|
304
|
-
try {
|
|
305
|
-
return await Promise.race([settled, expiry]);
|
|
306
|
-
} finally {
|
|
307
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
308
|
-
}
|
|
309
|
-
}
|