dsh-thesis 1.0.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/DESIGN.md +390 -0
- package/LICENSE +21 -0
- package/NOTICE.md +10 -0
- package/README.md +253 -0
- package/cordis.patch.yml +17 -0
- package/docs/host-api.md +640 -0
- package/lib/codewalk/commands.d.ts +23 -0
- package/lib/codewalk/commands.js +168 -0
- package/lib/codewalk/engine.d.ts +108 -0
- package/lib/codewalk/engine.js +449 -0
- package/lib/codewalk/errors.d.ts +31 -0
- package/lib/codewalk/errors.js +20 -0
- package/lib/codewalk/fs-types.d.ts +34 -0
- package/lib/codewalk/fs-types.js +10 -0
- package/lib/codewalk/gate.d.ts +30 -0
- package/lib/codewalk/gate.js +60 -0
- package/lib/codewalk/host-types.d.ts +198 -0
- package/lib/codewalk/host-types.js +11 -0
- package/lib/codewalk/prestep.d.ts +17 -0
- package/lib/codewalk/prestep.js +62 -0
- package/lib/codewalk/register.d.ts +23 -0
- package/lib/codewalk/register.js +31 -0
- package/lib/codewalk/skill.d.ts +15 -0
- package/lib/codewalk/skill.js +57 -0
- package/lib/codewalk/tools.d.ts +18 -0
- package/lib/codewalk/tools.js +314 -0
- package/lib/codewalk/types.d.ts +151 -0
- package/lib/codewalk/types.js +9 -0
- package/lib/commands.d.ts +33 -0
- package/lib/commands.js +60 -0
- package/lib/config.d.ts +125 -0
- package/lib/config.js +139 -0
- package/lib/dedup/commands.d.ts +21 -0
- package/lib/dedup/commands.js +93 -0
- package/lib/dedup/index.d.ts +75 -0
- package/lib/dedup/index.js +331 -0
- package/lib/dedup/normalize.d.ts +125 -0
- package/lib/dedup/normalize.js +329 -0
- package/lib/dedup/report.d.ts +114 -0
- package/lib/dedup/report.js +309 -0
- package/lib/dedup/rewrite.d.ts +71 -0
- package/lib/dedup/rewrite.js +228 -0
- package/lib/dedup/similarity.d.ts +169 -0
- package/lib/dedup/similarity.js +323 -0
- package/lib/gates/dangerous.d.ts +22 -0
- package/lib/gates/dangerous.js +47 -0
- package/lib/gates/detect.d.ts +54 -0
- package/lib/gates/detect.js +106 -0
- package/lib/gates/messages.d.ts +39 -0
- package/lib/gates/messages.js +74 -0
- package/lib/gates/prompt.d.ts +21 -0
- package/lib/gates/prompt.js +56 -0
- package/lib/gates/writing-intent.d.ts +49 -0
- package/lib/gates/writing-intent.js +68 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +76 -0
- package/lib/ingest/code.d.ts +71 -0
- package/lib/ingest/code.js +356 -0
- package/lib/ingest/commands.d.ts +23 -0
- package/lib/ingest/commands.js +74 -0
- package/lib/ingest/docx.d.ts +29 -0
- package/lib/ingest/docx.js +92 -0
- package/lib/ingest/index.d.ts +179 -0
- package/lib/ingest/index.js +640 -0
- package/lib/ingest/ooxml.d.ts +58 -0
- package/lib/ingest/ooxml.js +393 -0
- package/lib/ingest/pdf.d.ts +86 -0
- package/lib/ingest/pdf.js +1016 -0
- package/lib/ingest/pptx.d.ts +32 -0
- package/lib/ingest/pptx.js +116 -0
- package/lib/ingest/text.d.ts +25 -0
- package/lib/ingest/text.js +180 -0
- package/lib/ingest/types.d.ts +48 -0
- package/lib/ingest/types.js +47 -0
- package/lib/ingest/xlsx.d.ts +72 -0
- package/lib/ingest/xlsx.js +411 -0
- package/lib/ingest/zip.d.ts +76 -0
- package/lib/ingest/zip.js +393 -0
- package/lib/intake/definitions.d.ts +65 -0
- package/lib/intake/definitions.js +42 -0
- package/lib/intake/gate-rules.d.ts +57 -0
- package/lib/intake/gate-rules.js +118 -0
- package/lib/intake/gate.d.ts +24 -0
- package/lib/intake/gate.js +86 -0
- package/lib/intake/index.d.ts +76 -0
- package/lib/intake/index.js +468 -0
- package/lib/intake/questions.d.ts +169 -0
- package/lib/intake/questions.js +720 -0
- package/lib/intake/spec.d.ts +66 -0
- package/lib/intake/spec.js +221 -0
- package/lib/intake/state.d.ts +192 -0
- package/lib/intake/state.js +420 -0
- package/lib/memory/recall.d.ts +69 -0
- package/lib/memory/recall.js +177 -0
- package/lib/memory/register.d.ts +37 -0
- package/lib/memory/register.js +52 -0
- package/lib/memory/service.d.ts +39 -0
- package/lib/memory/service.js +39 -0
- package/lib/memory/store.d.ts +45 -0
- package/lib/memory/store.js +123 -0
- package/lib/memory/tools.d.ts +19 -0
- package/lib/memory/tools.js +195 -0
- package/lib/memory/types.d.ts +55 -0
- package/lib/memory/types.js +6 -0
- package/lib/paper/commands.d.ts +13 -0
- package/lib/paper/commands.js +84 -0
- package/lib/paper/lib/aicheck.d.ts +38 -0
- package/lib/paper/lib/aicheck.js +241 -0
- package/lib/paper/lib/bibtex.d.ts +21 -0
- package/lib/paper/lib/bibtex.js +114 -0
- package/lib/paper/lib/check.d.ts +32 -0
- package/lib/paper/lib/check.js +215 -0
- package/lib/paper/lib/defense.d.ts +48 -0
- package/lib/paper/lib/defense.js +220 -0
- package/lib/paper/lib/docx.d.ts +35 -0
- package/lib/paper/lib/docx.js +213 -0
- package/lib/paper/lib/layout.d.ts +41 -0
- package/lib/paper/lib/layout.js +572 -0
- package/lib/paper/lib/ledger.d.ts +112 -0
- package/lib/paper/lib/ledger.js +349 -0
- package/lib/paper/lib/lit-api.d.ts +59 -0
- package/lib/paper/lib/lit-api.js +296 -0
- package/lib/paper/lib/lit-cache.d.ts +32 -0
- package/lib/paper/lib/lit-cache.js +91 -0
- package/lib/paper/lib/project.d.ts +29 -0
- package/lib/paper/lib/project.js +93 -0
- package/lib/paper/lib/review.d.ts +37 -0
- package/lib/paper/lib/review.js +194 -0
- package/lib/paper/lib/zip.d.ts +14 -0
- package/lib/paper/lib/zip.js +124 -0
- package/lib/paper/register.d.ts +20 -0
- package/lib/paper/register.js +183 -0
- package/lib/paper/tools/aicheck.d.ts +6 -0
- package/lib/paper/tools/aicheck.js +38 -0
- package/lib/paper/tools/build.d.ts +27 -0
- package/lib/paper/tools/build.js +166 -0
- package/lib/paper/tools/check.d.ts +7 -0
- package/lib/paper/tools/check.js +79 -0
- package/lib/paper/tools/decision.d.ts +13 -0
- package/lib/paper/tools/decision.js +53 -0
- package/lib/paper/tools/defense.d.ts +11 -0
- package/lib/paper/tools/defense.js +74 -0
- package/lib/paper/tools/init.d.ts +23 -0
- package/lib/paper/tools/init.js +99 -0
- package/lib/paper/tools/lit.d.ts +27 -0
- package/lib/paper/tools/lit.js +236 -0
- package/lib/paper/tools/progress.d.ts +18 -0
- package/lib/paper/tools/progress.js +77 -0
- package/lib/paper/tools/review.d.ts +11 -0
- package/lib/paper/tools/review.js +41 -0
- package/lib/ppt/actions.d.ts +97 -0
- package/lib/ppt/actions.js +263 -0
- package/lib/ppt/check.d.ts +64 -0
- package/lib/ppt/check.js +185 -0
- package/lib/ppt/commands.d.ts +28 -0
- package/lib/ppt/commands.js +71 -0
- package/lib/ppt/convert.d.ts +146 -0
- package/lib/ppt/convert.js +423 -0
- package/lib/ppt/index.d.ts +45 -0
- package/lib/ppt/index.js +57 -0
- package/lib/ppt/marp.d.ts +74 -0
- package/lib/ppt/marp.js +165 -0
- package/lib/ppt/outline.d.ts +206 -0
- package/lib/ppt/outline.js +695 -0
- package/lib/shared/define-tool.d.ts +77 -0
- package/lib/shared/define-tool.js +235 -0
- package/lib/shared/fs-errors.d.ts +50 -0
- package/lib/shared/fs-errors.js +90 -0
- package/lib/shared/index.d.ts +31 -0
- package/lib/shared/index.js +32 -0
- package/lib/shared/spawn.d.ts +38 -0
- package/lib/shared/spawn.js +50 -0
- package/lib/shared/text.d.ts +14 -0
- package/lib/shared/text.js +26 -0
- package/package.json +82 -0
- package/skills/code-walkthrough/SKILL.md +94 -0
- package/skills/code-walkthrough/references/examples.md +129 -0
- package/skills/paper-dedup/SKILL.md +58 -0
- package/skills/paper-intake/SKILL.md +64 -0
- package/skills/thesis-citation/SKILL.md +67 -0
- package/skills/thesis-defense/SKILL.md +87 -0
- package/skills/thesis-eng-design/SKILL.md +97 -0
- package/skills/thesis-inquiry/SKILL.md +55 -0
- package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
- package/skills/thesis-literature/SKILL.md +56 -0
- package/skills/thesis-opener/SKILL.md +77 -0
- package/skills/thesis-pipeline/SKILL.md +121 -0
- package/skills/thesis-writing/SKILL.md +86 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 追问状态的落盘与读取:`.paper/intake.json`(相对论文工作区根)。
|
|
3
|
+
*
|
|
4
|
+
* 三条硬规则:
|
|
5
|
+
* 1. **原子写**:先写同目录临时文件,再改名覆盖目标。中途断电只会留下一个
|
|
6
|
+
* 可识别的临时文件,绝不会把状态写成半截 JSON。改名走可注入的
|
|
7
|
+
* {@link IntakeIo.rename} 接口——真机上是 `node:fs/promises` 的 rename,
|
|
8
|
+
* 测试里是内存实现,两条路径都必须走通。
|
|
9
|
+
* 2. **损坏时报错,不静默重置**:JSON 解析失败、版本不符、结构不对,一律抛出
|
|
10
|
+
* 带文件路径与修复建议的中文错误。悄悄重置会丢掉用户已经答过的每一个问题,
|
|
11
|
+
* 这比报错严重得多。
|
|
12
|
+
* 3. **断点续问**:状态是唯一的真相来源,新会话读回它就能从正确的问题继续。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-thesis/intake
|
|
15
|
+
*/
|
|
16
|
+
import * as nodePath from 'node:path';
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { isMissingError } from "../shared/fs-errors.js";
|
|
19
|
+
/** 状态文件相对论文工作区根的路径。 */
|
|
20
|
+
export const STATE_REL = '.paper/intake.json';
|
|
21
|
+
/** 状态 schema 版本;结构不兼容变更时必须递增(旧状态将明确报错而非被猜读)。 */
|
|
22
|
+
export const STATE_VERSION = 1;
|
|
23
|
+
/** 合法阶段集合(状态迁移校验用)。 */
|
|
24
|
+
export const INTAKE_STAGES = ['asking', 'done'];
|
|
25
|
+
/** 建一份全新状态。 */
|
|
26
|
+
export function defaultState(now = new Date(), specPath = '') {
|
|
27
|
+
const iso = now.toISOString();
|
|
28
|
+
return {
|
|
29
|
+
version: STATE_VERSION,
|
|
30
|
+
createdAt: iso,
|
|
31
|
+
updatedAt: iso,
|
|
32
|
+
stage: 'asking',
|
|
33
|
+
askedIds: [],
|
|
34
|
+
answers: {},
|
|
35
|
+
skipped: [],
|
|
36
|
+
skipReasons: {},
|
|
37
|
+
specPath,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// 序列化与宽容解析
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/** 稳定序列化:键序固定、2 空格缩进、结尾换行(同样的状态 → 同样的字节)。 */
|
|
44
|
+
export function serializeState(state) {
|
|
45
|
+
const answers = {};
|
|
46
|
+
for (const id of Object.keys(state.answers).sort()) {
|
|
47
|
+
const rec = state.answers[id];
|
|
48
|
+
const out = {
|
|
49
|
+
value: rec.value,
|
|
50
|
+
at: rec.at,
|
|
51
|
+
source: rec.source,
|
|
52
|
+
confirmed: rec.confirmed,
|
|
53
|
+
};
|
|
54
|
+
if (rec.valid !== undefined)
|
|
55
|
+
out.valid = rec.valid;
|
|
56
|
+
if (rec.note !== undefined)
|
|
57
|
+
out.note = rec.note;
|
|
58
|
+
answers[id] = out;
|
|
59
|
+
}
|
|
60
|
+
const ordered = {
|
|
61
|
+
version: state.version,
|
|
62
|
+
createdAt: state.createdAt,
|
|
63
|
+
updatedAt: state.updatedAt,
|
|
64
|
+
stage: state.stage,
|
|
65
|
+
askedIds: [...state.askedIds],
|
|
66
|
+
answers,
|
|
67
|
+
skipped: [...state.skipped],
|
|
68
|
+
skipReasons: { ...state.skipReasons },
|
|
69
|
+
specPath: state.specPath,
|
|
70
|
+
};
|
|
71
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
72
|
+
}
|
|
73
|
+
/** 状态损坏:带路径与修复建议的可读错误。 */
|
|
74
|
+
export class IntakeStateError extends Error {
|
|
75
|
+
path;
|
|
76
|
+
constructor(path, detail) {
|
|
77
|
+
super(`追问状态文件无法使用:${path}\n原因:${detail}\n` +
|
|
78
|
+
`处理建议:修正该 JSON(或先备份后删除它,用 thesis_intake action=start 重新开始追问)。` +
|
|
79
|
+
`请勿手工把 answers 改空——那会丢掉已确认的全部答案。`);
|
|
80
|
+
this.name = 'IntakeStateError';
|
|
81
|
+
this.path = path;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function asStringArray(value) {
|
|
85
|
+
if (!Array.isArray(value))
|
|
86
|
+
return [];
|
|
87
|
+
return value.filter((v) => typeof v === 'string');
|
|
88
|
+
}
|
|
89
|
+
function asStringRecord(value) {
|
|
90
|
+
const out = {};
|
|
91
|
+
if (value === null || typeof value !== 'object')
|
|
92
|
+
return out;
|
|
93
|
+
for (const [k, v] of Object.entries(value)) {
|
|
94
|
+
if (typeof v === 'string')
|
|
95
|
+
out[k] = v;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function parseAnswers(value) {
|
|
100
|
+
const out = {};
|
|
101
|
+
if (value === null || typeof value !== 'object')
|
|
102
|
+
return out;
|
|
103
|
+
for (const [id, raw] of Object.entries(value)) {
|
|
104
|
+
if (raw === null || typeof raw !== 'object')
|
|
105
|
+
continue;
|
|
106
|
+
const rec = raw;
|
|
107
|
+
if (typeof rec.value !== 'string' || rec.value.trim() === '')
|
|
108
|
+
continue;
|
|
109
|
+
const answer = {
|
|
110
|
+
value: rec.value,
|
|
111
|
+
at: typeof rec.at === 'string' ? rec.at : new Date().toISOString(),
|
|
112
|
+
source: rec.source === 'materials' ? 'materials' : 'user',
|
|
113
|
+
confirmed: rec.confirmed === true,
|
|
114
|
+
};
|
|
115
|
+
if (typeof rec.valid === 'boolean')
|
|
116
|
+
answer.valid = rec.valid;
|
|
117
|
+
if (typeof rec.note === 'string')
|
|
118
|
+
answer.note = rec.note;
|
|
119
|
+
out[id] = answer;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 解析状态文本。任何结构性问题都抛出 {@link IntakeStateError},
|
|
125
|
+
* 绝不返回「看起来能用」的默认状态。
|
|
126
|
+
*/
|
|
127
|
+
export function parseState(raw, path = STATE_REL) {
|
|
128
|
+
let data;
|
|
129
|
+
try {
|
|
130
|
+
data = JSON.parse(raw);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw new IntakeStateError(path, `不是合法 JSON(${error instanceof Error ? error.message : String(error)})`);
|
|
134
|
+
}
|
|
135
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
136
|
+
throw new IntakeStateError(path, '顶层不是 JSON 对象');
|
|
137
|
+
}
|
|
138
|
+
const obj = data;
|
|
139
|
+
if (obj.version !== STATE_VERSION) {
|
|
140
|
+
throw new IntakeStateError(path, `version 期望 ${STATE_VERSION},实际 ${JSON.stringify(obj.version)}(可能是旧版本或被别的工具改写)`);
|
|
141
|
+
}
|
|
142
|
+
const stage = obj.stage === 'done' ? 'done' : 'asking';
|
|
143
|
+
const specPath = typeof obj.specPath === 'string' ? obj.specPath : '';
|
|
144
|
+
const iso = new Date().toISOString();
|
|
145
|
+
return {
|
|
146
|
+
version: STATE_VERSION,
|
|
147
|
+
createdAt: typeof obj.createdAt === 'string' ? obj.createdAt : iso,
|
|
148
|
+
updatedAt: typeof obj.updatedAt === 'string' ? obj.updatedAt : iso,
|
|
149
|
+
stage,
|
|
150
|
+
askedIds: asStringArray(obj.askedIds),
|
|
151
|
+
answers: parseAnswers(obj.answers),
|
|
152
|
+
skipped: asStringArray(obj.skipped),
|
|
153
|
+
skipReasons: asStringRecord(obj.skipReasons),
|
|
154
|
+
specPath,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 校验「把阶段推进到 to」是否被允许。
|
|
159
|
+
*
|
|
160
|
+
* 规则:`asking → done` 只能由 `done` 动作在必答项齐全后触发(由调用方保证);
|
|
161
|
+
* 已经 `done` 的状态允许重答问题,但不会被自动退回 `asking`——回退必须显式
|
|
162
|
+
* 重答一个必答项(见 {@link applyAnswer}),避免状态在两次读取间莫名倒退。
|
|
163
|
+
*/
|
|
164
|
+
export function canTransition(state, to) {
|
|
165
|
+
if (!INTAKE_STAGES.includes(to)) {
|
|
166
|
+
return { ok: false, reason: `未知阶段:${String(to)}。有效值:${INTAKE_STAGES.join('、')}。` };
|
|
167
|
+
}
|
|
168
|
+
if (state.stage === to)
|
|
169
|
+
return { ok: true, reason: '' };
|
|
170
|
+
if (state.stage === 'done' && to === 'asking') {
|
|
171
|
+
return { ok: false, reason: '已完成的追问不会自动退回进行中;如发现答案有误,直接重答该问题即可。' };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, reason: '' };
|
|
174
|
+
}
|
|
175
|
+
/** 校验通过后写入阶段;不通过时返回原状态(调用方负责提示原因)。 */
|
|
176
|
+
export function setStage(state, to, now = new Date()) {
|
|
177
|
+
const check = canTransition(state, to);
|
|
178
|
+
if (!check.ok)
|
|
179
|
+
return check;
|
|
180
|
+
state.stage = to;
|
|
181
|
+
state.updatedAt = now.toISOString();
|
|
182
|
+
return { ok: true, reason: '' };
|
|
183
|
+
}
|
|
184
|
+
/** 记录一次「已提问」(幂等:重复记同一个 id 不改变状态)。 */
|
|
185
|
+
export function markAsked(state, id, now = new Date()) {
|
|
186
|
+
if (state.askedIds.includes(id))
|
|
187
|
+
return false;
|
|
188
|
+
state.askedIds.push(id);
|
|
189
|
+
state.updatedAt = now.toISOString();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 写入一条答案。**幂等**:`value` 与原值完全一致时不改动 `at`/`updatedAt`,
|
|
194
|
+
* 返回 `changed:false`——重复提交同一个答案不会污染历史。
|
|
195
|
+
*/
|
|
196
|
+
export function applyAnswer(state, id, value, opts = {}) {
|
|
197
|
+
const now = opts.now ?? new Date();
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
const existing = state.answers[id];
|
|
200
|
+
if (existing !== undefined && existing.value === trimmed) {
|
|
201
|
+
// 同样的答案:只更新确认位(材料推断 → 用户确认是唯一有意义的变化)。
|
|
202
|
+
const wantConfirmed = opts.confirmed ?? true;
|
|
203
|
+
if (existing.confirmed !== wantConfirmed) {
|
|
204
|
+
existing.confirmed = wantConfirmed;
|
|
205
|
+
state.updatedAt = now.toISOString();
|
|
206
|
+
return { changed: true, record: existing };
|
|
207
|
+
}
|
|
208
|
+
return { changed: false, record: existing };
|
|
209
|
+
}
|
|
210
|
+
const record = {
|
|
211
|
+
value: trimmed,
|
|
212
|
+
at: now.toISOString(),
|
|
213
|
+
source: opts.source ?? 'user',
|
|
214
|
+
confirmed: opts.confirmed ?? true,
|
|
215
|
+
};
|
|
216
|
+
if (opts.valid !== undefined)
|
|
217
|
+
record.valid = opts.valid;
|
|
218
|
+
if (opts.note !== undefined)
|
|
219
|
+
record.note = opts.note;
|
|
220
|
+
state.answers[id] = record;
|
|
221
|
+
state.skipped = state.skipped.filter(s => s !== id);
|
|
222
|
+
delete state.skipReasons[id];
|
|
223
|
+
state.updatedAt = now.toISOString();
|
|
224
|
+
// 重答必答项后,已完成的追问回到进行中:规格必须重新收敛。
|
|
225
|
+
if (state.stage === 'done' && record.valid !== false) {
|
|
226
|
+
state.stage = 'asking';
|
|
227
|
+
}
|
|
228
|
+
return { changed: true, record };
|
|
229
|
+
}
|
|
230
|
+
/** 记录一次跳过(必须带理由;跳过必答项会让 `done` 拒绝)。 */
|
|
231
|
+
export function applySkip(state, id, reason, now = new Date()) {
|
|
232
|
+
if (!state.skipped.includes(id))
|
|
233
|
+
state.skipped.push(id);
|
|
234
|
+
state.skipReasons[id] = reason.trim();
|
|
235
|
+
state.updatedAt = now.toISOString();
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 用 `ctx.fs` 拼一个 I/O;默认改名走 `node:fs/promises`(真机路径)。
|
|
239
|
+
*
|
|
240
|
+
* **`read` 只把「确认不存在」折叠成 `undefined`**:权限与 IO 错误一律上抛。
|
|
241
|
+
* 否则调用方会以为状态还没建过,用默认态覆盖掉用户已答的每一个问题
|
|
242
|
+
* (详见 `shared/fs-errors.ts`)。
|
|
243
|
+
*/
|
|
244
|
+
export function fsIo(fs, signal, rename) {
|
|
245
|
+
return {
|
|
246
|
+
async read(path) {
|
|
247
|
+
try {
|
|
248
|
+
return await fs.readText(await fs.resolve(path, { signal }), signal);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (isMissingError(error))
|
|
252
|
+
return undefined;
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
async write(path, content) {
|
|
257
|
+
await fs.writeText(await fs.resolve(path, { signal }), content, undefined, signal);
|
|
258
|
+
},
|
|
259
|
+
async exists(path) {
|
|
260
|
+
const info = await fs.stat(await fs.resolve(path, { signal }));
|
|
261
|
+
return info !== undefined && !info.isDirectory;
|
|
262
|
+
},
|
|
263
|
+
async listDir(path) {
|
|
264
|
+
try {
|
|
265
|
+
const entries = await fs.listDir(await fs.resolve(path, { signal }), signal);
|
|
266
|
+
return entries.map(e => e.name);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// 目录不存在 = 空目录;读失败必须上抛,否则「材料清单读不出来」会被当成「没有材料」。
|
|
270
|
+
if (isMissingError(error))
|
|
271
|
+
return [];
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
...(rename === undefined
|
|
276
|
+
? {}
|
|
277
|
+
: {
|
|
278
|
+
async rename(from, to) {
|
|
279
|
+
await rename(from, to);
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
async remove(path) {
|
|
283
|
+
// `ctx.fs` 没有删除原语,写空内容即可;残留的空临时文件不影响正确性。
|
|
284
|
+
await fs.writeText(await fs.resolve(path, { signal }), '', undefined, signal).catch(() => undefined);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** 真机改名:`node:fs/promises` 的 readFile + writeFile(同一目录内的替换)。 */
|
|
289
|
+
export const nodeRename = async (from, to) => {
|
|
290
|
+
const data = await readFile(from, 'utf8');
|
|
291
|
+
await writeFile(to, data, 'utf8');
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* 原子写:先写临时文件,再改名覆盖目标。
|
|
295
|
+
*
|
|
296
|
+
* 有 `io.rename` 时是真改名;没有时退化为「写临时 → 写目标 → 清临时」。
|
|
297
|
+
* 任何一步失败都会尽力清掉临时文件,并把错误原样抛给调用方(不吞)。
|
|
298
|
+
*/
|
|
299
|
+
export async function writeAtomic(io, path, content) {
|
|
300
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
301
|
+
await io.write(tmp, content);
|
|
302
|
+
try {
|
|
303
|
+
if (io.rename !== undefined) {
|
|
304
|
+
await io.rename(tmp, path);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await io.write(path, content);
|
|
308
|
+
await io.remove?.(tmp);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
await io.remove?.(tmp);
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** 读取状态;文件不存在返回 `undefined`(由调用方决定是否初始化)。 */
|
|
317
|
+
export async function loadState(io, root) {
|
|
318
|
+
const path = nodePath.join(root, STATE_REL);
|
|
319
|
+
const raw = await io.read(path);
|
|
320
|
+
if (raw === undefined)
|
|
321
|
+
return undefined;
|
|
322
|
+
return parseState(raw, path);
|
|
323
|
+
}
|
|
324
|
+
/** 原子落盘状态。 */
|
|
325
|
+
export async function saveState(io, root, state, now = new Date()) {
|
|
326
|
+
state.updatedAt = now.toISOString();
|
|
327
|
+
await writeAtomic(io, nodePath.join(root, STATE_REL), serializeState(state));
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// 材料清单与台账:宽容读取
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/** `thesis_ingest` 的材料清单相对路径。 */
|
|
333
|
+
export const MATERIALS_REL = '00-管理/材料清单.md';
|
|
334
|
+
/** 台账相对路径(复用 paper 模块的约定,但不 import 它,避免模块间耦合成环)。 */
|
|
335
|
+
const LEDGER_REL = '00-管理/进度台账.md';
|
|
336
|
+
const EXT_RE = /\.([A-Za-z0-9]{1,5})$/;
|
|
337
|
+
function extOf(path) {
|
|
338
|
+
const m = EXT_RE.exec(path);
|
|
339
|
+
return m === null ? '' : m[1].toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
/** 去掉 Markdown 的包裹标记(反引号、加粗星号)与首尾空白。 */
|
|
342
|
+
function unwrapCell(cell) {
|
|
343
|
+
return cell.replace(/[`*]/g, '').trim();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* 宽容解析材料清单 Markdown。
|
|
347
|
+
*
|
|
348
|
+
* 只认「一行的表格里有 `.扩展名` 的单元格」:取该行第一个(剥掉反引号/星号后)
|
|
349
|
+
* 含扩展名的单元格为路径,其余单元格里第一个非空、非数字的当类型。
|
|
350
|
+
* 表头、分隔行、段落一律跳过。
|
|
351
|
+
* 解析不出任何条目时返回空清单——宁可多问一个,也不要凭猜免问。
|
|
352
|
+
*/
|
|
353
|
+
export function parseMaterials(md) {
|
|
354
|
+
const items = [];
|
|
355
|
+
for (const line of md.split(/\r?\n/)) {
|
|
356
|
+
if (!line.includes('|'))
|
|
357
|
+
continue;
|
|
358
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c.length > 0);
|
|
359
|
+
if (cells.length === 0)
|
|
360
|
+
continue;
|
|
361
|
+
if (cells.every(c => /^:?-{2,}:?$/.test(c)))
|
|
362
|
+
continue; // 表格分隔行
|
|
363
|
+
// 路径优先:剥掉反引号后能看出扩展名的那个单元格。
|
|
364
|
+
const pathCell = cells.find(c => EXT_RE.test(unwrapCell(c)) && !/^[-–—\s]*$/.test(c));
|
|
365
|
+
if (pathCell === undefined)
|
|
366
|
+
continue;
|
|
367
|
+
const path = unwrapCell(pathCell);
|
|
368
|
+
if (path === '')
|
|
369
|
+
continue;
|
|
370
|
+
const kind = cells.find(c => c !== pathCell && !/^\d+(\.\d+)?\s*(B|KB|MB|GB)?$/i.test(c) && !/^[-–—\s]*$/.test(c)) ?? '';
|
|
371
|
+
items.push({ path, kind: unwrapCell(kind), ext: extOf(path) });
|
|
372
|
+
}
|
|
373
|
+
return { found: items.length > 0, items };
|
|
374
|
+
}
|
|
375
|
+
/** 读材料清单;缺失或为空返回 `{ found:false, items:[] }`。 */
|
|
376
|
+
export async function loadMaterials(io, root) {
|
|
377
|
+
const raw = await io.read(nodePath.join(root, MATERIALS_REL));
|
|
378
|
+
if (raw === undefined)
|
|
379
|
+
return { found: false, items: [] };
|
|
380
|
+
return parseMaterials(raw);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 从进度台账推断当前阶段名。
|
|
384
|
+
*
|
|
385
|
+
* 台账的机器真相是 `<!-- thesis:state ... -->` 里的 `currentStage`(数字),
|
|
386
|
+
* 这里只做「读到就用、读不到就按开题前」的宽容推断,绝不因为台账异常而中断追问。
|
|
387
|
+
*/
|
|
388
|
+
export function stageFromLedger(md) {
|
|
389
|
+
if (md === undefined)
|
|
390
|
+
return undefined;
|
|
391
|
+
const block = /<!-- thesis:state\n([\s\S]*?)\n-->/.exec(md);
|
|
392
|
+
if (block === null || block[1] === undefined)
|
|
393
|
+
return undefined;
|
|
394
|
+
let stage;
|
|
395
|
+
try {
|
|
396
|
+
stage = JSON.parse(block[1]).currentStage;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
if (typeof stage !== 'number' || !Number.isFinite(stage))
|
|
402
|
+
return undefined;
|
|
403
|
+
const names = {
|
|
404
|
+
1: '选题',
|
|
405
|
+
2: '开题',
|
|
406
|
+
3: '文献调研',
|
|
407
|
+
4: '系统设计',
|
|
408
|
+
5: '系统实现',
|
|
409
|
+
6: '系统测试',
|
|
410
|
+
7: '论文撰写',
|
|
411
|
+
8: '定稿与合规',
|
|
412
|
+
9: '答辩',
|
|
413
|
+
};
|
|
414
|
+
return names[Math.floor(stage)];
|
|
415
|
+
}
|
|
416
|
+
/** 读台账并推断阶段(读不到返回 undefined,由 {@link makeContext} 落到默认值)。 */
|
|
417
|
+
export async function loadStage(io, root) {
|
|
418
|
+
return stageFromLedger(await io.read(nodePath.join(root, LEDGER_REL)));
|
|
419
|
+
}
|
|
420
|
+
export { LEDGER_REL };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆回灌:把「用户已知的稳定事实」在相关时自动放回上下文。
|
|
3
|
+
*
|
|
4
|
+
* 只提供 `fact_search/remember/context` 三个工具的跨会话事实是**被动**的——模型得先想起来去查。
|
|
5
|
+
* 真实使用里最常发生的是:用户上次说过「我们学校要求页边距 3cm、查重阈值 20%」,
|
|
6
|
+
* 这次新开会话问「帮我写绪论」,模型不知道这条约束,于是白写一版。
|
|
7
|
+
*
|
|
8
|
+
* 所以这里补一个 `agent/pre-step` 监听:当用户这句话与事实库里的 Key/Value 有词面重叠时,
|
|
9
|
+
* 注入一张**有界**的事实卡片(默认最多 4 条 / 600 字符)。四条护栏:
|
|
10
|
+
*
|
|
11
|
+
* 1. **只在相关时出现**:没有任何关键词命中就一个字节都不注入(避免每条消息都塞满事实);
|
|
12
|
+
* 2. **有界**:条数与字符数都封顶,且明确标注「可能过时,冲突时以用户当前说法为准」;
|
|
13
|
+
* 3. **不重复**:按 agent 记住上次注入时的事实指纹(最大 updatedAt + 条数),未变化不再注入;
|
|
14
|
+
* 4. **永不抛进 agent 循环**:取事实、算关键词、渲染全部包在 try 里。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-thesis/memory/recall
|
|
17
|
+
*/
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
19
|
+
import type { MemoryEntry } from './service.ts';
|
|
20
|
+
/** 记忆回灌参数。 */
|
|
21
|
+
export interface RecallOptions {
|
|
22
|
+
/** 单次最多注入几条记忆。 */
|
|
23
|
+
maxEntries: number;
|
|
24
|
+
/** 注入卡片的最大字符数(含标题与说明)。 */
|
|
25
|
+
maxChars: number;
|
|
26
|
+
/** 参与匹配的关键词最短长度。 */
|
|
27
|
+
minKeywordLength: number;
|
|
28
|
+
}
|
|
29
|
+
/** 出厂默认值(与 `PAPER_DEFAULTS.recall` 保持一致,单一来源见 `src/config.ts`)。 */
|
|
30
|
+
export declare const RECALL_DEFAULTS: RecallOptions;
|
|
31
|
+
/**
|
|
32
|
+
* 提取用于匹配的关键词:中文按 2-gram 滑窗、拉丁按词,去停用词并按长度降序(长词更具体)。
|
|
33
|
+
*
|
|
34
|
+
* 中文没有空格,直接用整句匹配会永远命中不了;用 2-gram 能把「查重阈值」拆成
|
|
35
|
+
* 「查重」「重阈」「阈值」这样的片段,与记忆里的同名字段天然对齐。
|
|
36
|
+
*/
|
|
37
|
+
export declare function keywordsOf(text: string, minLength?: number): string[];
|
|
38
|
+
/** 一条记忆与关键词集合的重叠得分(命中数为主,置信度与更新时间作微调)。 */
|
|
39
|
+
export declare function scoreEntry(entry: MemoryEntry, keywords: readonly string[]): number;
|
|
40
|
+
/**
|
|
41
|
+
* 选出与关键词相关的记忆:至少命中 1 个词,按得分降序,同分取更新更近的,截断到上限。
|
|
42
|
+
*/
|
|
43
|
+
export declare function selectRelevant(entries: readonly MemoryEntry[], keywords: readonly string[], maxEntries?: number): MemoryEntry[];
|
|
44
|
+
/** 卡片正文(不含 source 包装)。 */
|
|
45
|
+
export declare function renderRecallCard(entries: readonly MemoryEntry[], maxChars: number): string;
|
|
46
|
+
/** 构造注入消息(user 角色、插件来源、snapshot 形态)。 */
|
|
47
|
+
export declare function makeRecallMessage(entries: readonly MemoryEntry[], maxChars: number): {
|
|
48
|
+
id: string;
|
|
49
|
+
role: 'user';
|
|
50
|
+
content: Array<{
|
|
51
|
+
type: 'text';
|
|
52
|
+
text: string;
|
|
53
|
+
}>;
|
|
54
|
+
source: {
|
|
55
|
+
kind: string;
|
|
56
|
+
plugin: string;
|
|
57
|
+
form: string;
|
|
58
|
+
sections: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
text: string;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* 安装跨会话事实回灌监听。
|
|
66
|
+
*
|
|
67
|
+
* 需要一个已注册的 `ctx.memory`(由 `registerFacts` 提供);没有则静默不装。
|
|
68
|
+
*/
|
|
69
|
+
export declare function installFactRecall(ctx: Context, options?: RecallOptions): void;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆回灌:把「用户已知的稳定事实」在相关时自动放回上下文。
|
|
3
|
+
*
|
|
4
|
+
* 只提供 `fact_search/remember/context` 三个工具的跨会话事实是**被动**的——模型得先想起来去查。
|
|
5
|
+
* 真实使用里最常发生的是:用户上次说过「我们学校要求页边距 3cm、查重阈值 20%」,
|
|
6
|
+
* 这次新开会话问「帮我写绪论」,模型不知道这条约束,于是白写一版。
|
|
7
|
+
*
|
|
8
|
+
* 所以这里补一个 `agent/pre-step` 监听:当用户这句话与事实库里的 Key/Value 有词面重叠时,
|
|
9
|
+
* 注入一张**有界**的事实卡片(默认最多 4 条 / 600 字符)。四条护栏:
|
|
10
|
+
*
|
|
11
|
+
* 1. **只在相关时出现**:没有任何关键词命中就一个字节都不注入(避免每条消息都塞满事实);
|
|
12
|
+
* 2. **有界**:条数与字符数都封顶,且明确标注「可能过时,冲突时以用户当前说法为准」;
|
|
13
|
+
* 3. **不重复**:按 agent 记住上次注入时的事实指纹(最大 updatedAt + 条数),未变化不再注入;
|
|
14
|
+
* 4. **永不抛进 agent 循环**:取事实、算关键词、渲染全部包在 try 里。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-thesis/memory/recall
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { textOfBlocks } from "../shared/text.js";
|
|
20
|
+
/** 出厂默认值(与 `PAPER_DEFAULTS.recall` 保持一致,单一来源见 `src/config.ts`)。 */
|
|
21
|
+
export const RECALL_DEFAULTS = { maxEntries: 4, maxChars: 600, minKeywordLength: 2 };
|
|
22
|
+
/** 停用词(中英混合,只去最吵的一批;不求语言学完备)。 */
|
|
23
|
+
const STOP_WORDS = new Set([
|
|
24
|
+
'的', '了', '是', '在', '我', '你', '他', '她', '它', '们', '这', '那', '有', '和', '与', '或',
|
|
25
|
+
'把', '被', '让', '给', '对', '为', '从', '到', '就', '都', '也', '还', '很', '要', '会', '能',
|
|
26
|
+
'一个', '什么', '怎么', '如何', '可以', '需要', '现在', '我们', '你们', '他们', '这个', '那个',
|
|
27
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'to', 'of', 'in', 'on', 'for', 'and', 'or',
|
|
28
|
+
'it', 'this', 'that', 'with', 'my', 'me', 'you', 'we', 'please', 'help',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* 提取用于匹配的关键词:中文按 2-gram 滑窗、拉丁按词,去停用词并按长度降序(长词更具体)。
|
|
32
|
+
*
|
|
33
|
+
* 中文没有空格,直接用整句匹配会永远命中不了;用 2-gram 能把「查重阈值」拆成
|
|
34
|
+
* 「查重」「重阈」「阈值」这样的片段,与记忆里的同名字段天然对齐。
|
|
35
|
+
*/
|
|
36
|
+
export function keywordsOf(text, minLength = RECALL_DEFAULTS.minKeywordLength) {
|
|
37
|
+
const found = new Set();
|
|
38
|
+
const latin = text.toLowerCase().match(/[a-z][a-z0-9_.-]{1,30}/g) ?? [];
|
|
39
|
+
for (const word of latin) {
|
|
40
|
+
if (!STOP_WORDS.has(word) && word.length >= minLength)
|
|
41
|
+
found.add(word);
|
|
42
|
+
}
|
|
43
|
+
const han = text.match(/[\u4e00-\u9fa5]{2,}/g) ?? [];
|
|
44
|
+
for (const run of han) {
|
|
45
|
+
if (run.length <= 4) {
|
|
46
|
+
if (!STOP_WORDS.has(run))
|
|
47
|
+
found.add(run);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i + 2 <= run.length; i += 1) {
|
|
51
|
+
const gram = run.slice(i, i + 2);
|
|
52
|
+
if (!STOP_WORDS.has(gram))
|
|
53
|
+
found.add(gram);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...found].sort((a, b) => b.length - a.length || a.localeCompare(b)).slice(0, 24);
|
|
57
|
+
}
|
|
58
|
+
/** 一条记忆与关键词集合的重叠得分(命中数为主,置信度与更新时间作微调)。 */
|
|
59
|
+
export function scoreEntry(entry, keywords) {
|
|
60
|
+
const haystack = `${entry.key}\n${entry.value}`.toLowerCase();
|
|
61
|
+
let hits = 0;
|
|
62
|
+
for (const keyword of keywords) {
|
|
63
|
+
if (haystack.includes(keyword.toLowerCase()))
|
|
64
|
+
hits += 1;
|
|
65
|
+
}
|
|
66
|
+
if (hits === 0)
|
|
67
|
+
return 0;
|
|
68
|
+
return hits + entry.confidence * 0.01;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 选出与关键词相关的记忆:至少命中 1 个词,按得分降序,同分取更新更近的,截断到上限。
|
|
72
|
+
*/
|
|
73
|
+
export function selectRelevant(entries, keywords, maxEntries = RECALL_DEFAULTS.maxEntries) {
|
|
74
|
+
return entries
|
|
75
|
+
.map(entry => ({ entry, score: scoreEntry(entry, keywords) }))
|
|
76
|
+
.filter(item => item.score > 0)
|
|
77
|
+
.sort((a, b) => b.score - a.score || b.entry.updatedAt - a.entry.updatedAt)
|
|
78
|
+
.slice(0, Math.max(0, maxEntries))
|
|
79
|
+
.map(item => item.entry);
|
|
80
|
+
}
|
|
81
|
+
/** 卡片正文(不含 source 包装)。 */
|
|
82
|
+
export function renderRecallCard(entries, maxChars) {
|
|
83
|
+
const header = '【已确认事实 · dsh-thesis】以下是你以前确认过的稳定事实(跨会话记住的,可能过时;与用户当前说法冲突时以用户为准):';
|
|
84
|
+
const lines = [header];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const line = `- ${entry.key} = ${entry.value}`;
|
|
87
|
+
if (lines.join('\n').length + line.length + 1 > maxChars) {
|
|
88
|
+
lines.push('- …(还有更多事实,需要时用 fact_context 查询)');
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
lines.push(line);
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n').slice(0, maxChars);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 取出全部事实用于相关性打分。
|
|
97
|
+
*
|
|
98
|
+
* 优先 `context('')`(语义就是「列出全部」);只实现了 `search` 的替身退回
|
|
99
|
+
* `search('', …)`,这样回灌在两种服务形状下都能工作。
|
|
100
|
+
*/
|
|
101
|
+
function listAllFacts(service) {
|
|
102
|
+
if (typeof service.context === 'function')
|
|
103
|
+
return service.context('', { limit: 500 });
|
|
104
|
+
return service.search('', { limit: 500 });
|
|
105
|
+
}
|
|
106
|
+
/** 构造注入消息(user 角色、插件来源、snapshot 形态)。 */
|
|
107
|
+
export function makeRecallMessage(entries, maxChars) {
|
|
108
|
+
const text = renderRecallCard(entries, maxChars);
|
|
109
|
+
return {
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
role: 'user',
|
|
112
|
+
content: [{ type: 'text', text }],
|
|
113
|
+
source: {
|
|
114
|
+
kind: 'plugin',
|
|
115
|
+
plugin: 'dsh-thesis',
|
|
116
|
+
form: 'snapshot',
|
|
117
|
+
sections: [{ name: '已确认事实', text }],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** 记忆指纹:用于「记忆没变就不再注入」。 */
|
|
122
|
+
function fingerprint(entries) {
|
|
123
|
+
let latest = 0;
|
|
124
|
+
for (const entry of entries)
|
|
125
|
+
latest = Math.max(latest, entry.updatedAt);
|
|
126
|
+
return `${entries.length}:${latest}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 安装跨会话事实回灌监听。
|
|
130
|
+
*
|
|
131
|
+
* 需要一个已注册的 `ctx.memory`(由 `registerFacts` 提供);没有则静默不装。
|
|
132
|
+
*/
|
|
133
|
+
export function installFactRecall(ctx, options = RECALL_DEFAULTS) {
|
|
134
|
+
const lastInjected = new WeakMap();
|
|
135
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
136
|
+
const downstream = await next();
|
|
137
|
+
if (downstream.kind !== 'enter')
|
|
138
|
+
return downstream;
|
|
139
|
+
try {
|
|
140
|
+
const messages = payload.messages;
|
|
141
|
+
const last = messages?.[messages.length - 1];
|
|
142
|
+
if (last === undefined || last.source?.kind !== 'user')
|
|
143
|
+
return downstream;
|
|
144
|
+
const text = textOfBlocks((last.content ?? []));
|
|
145
|
+
if (text.trim().length < 8 || text.trim().startsWith('/'))
|
|
146
|
+
return downstream;
|
|
147
|
+
const service = ctx.get('memory');
|
|
148
|
+
if (service === undefined || typeof service.search !== 'function')
|
|
149
|
+
return downstream;
|
|
150
|
+
// 取全量事实用于相关性打分。注意不能用 `search('')`:空 query 会抛错
|
|
151
|
+
// (避免"匹配一切"的静默语义)。优先用 `context('')`——它的语义就是"列出全部";
|
|
152
|
+
// 只有实现了 search 的替身才退回 search(此时按老语义传空 query)。
|
|
153
|
+
const entries = listAllFacts(service);
|
|
154
|
+
if (entries.length === 0)
|
|
155
|
+
return downstream;
|
|
156
|
+
const keywords = keywordsOf(text, options.minKeywordLength);
|
|
157
|
+
if (keywords.length === 0)
|
|
158
|
+
return downstream;
|
|
159
|
+
const relevant = selectRelevant(entries, keywords, options.maxEntries);
|
|
160
|
+
if (relevant.length === 0)
|
|
161
|
+
return downstream;
|
|
162
|
+
const agent = payload.agent;
|
|
163
|
+
if (agent !== undefined) {
|
|
164
|
+
const mark = fingerprint(entries);
|
|
165
|
+
if (lastInjected.get(agent) === mark)
|
|
166
|
+
return downstream;
|
|
167
|
+
lastInjected.set(agent, mark);
|
|
168
|
+
}
|
|
169
|
+
const injected = makeRecallMessage(relevant, options.maxChars);
|
|
170
|
+
return { kind: 'enter', messages: [...downstream.messages, injected] };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// 记忆回灌是增益功能:任何异常都不允许影响对话本身。
|
|
174
|
+
return downstream;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|