@oh-my-student/plugin-presets 0.0.0-stage → 0.1.1
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/COPYRIGHT +5 -0
- package/README.md +6 -2
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/canvas-tool.js +56 -0
- package/catalog.js +34 -0
- package/drawing-tool.js +109 -0
- package/index.js +68 -0
- package/isolation.js +19 -0
- package/package.json +66 -4
- package/policy.js +98 -0
- package/prompt.js +44 -0
- package/resources/base.md +72 -0
- package/resources/companions/claude.md +5 -0
- package/resources/companions/deepseek.md +7 -0
- package/resources/companions/gemini.md +5 -0
- package/resources/companions/gpt.md +5 -0
- package/resources/coordinator.md +44 -0
- package/resources/manifest.json +41 -0
- package/resources/pi-skills-built/notara-examiner-analysis/SKILL.md +29 -0
- package/resources/pi-skills-built/notara-pdf-learning-set/SKILL.md +44 -0
- package/resources/pi-skills-built/notara-quest-orchestrator/SKILL.md +49 -0
- package/resources/pi-skills-built/notara-quest-tutor/SKILL.md +119 -0
- package/resources/pi-skills-built/notara-review-brainstorm/SKILL.md +34 -0
- package/resources/provenance.json +23 -0
- package/resources/workers/base.md +26 -0
- package/resources/workers/exercise.md +7 -0
- package/resources/workers/general.md +6 -0
- package/resources/workers/lesson.md +6 -0
- package/resources/workers/problem.md +7 -0
- package/resources/workers/review.md +6 -0
- package/resources.js +22 -0
- package/review-context.js +70 -0
- package/tools.js +118 -0
- package/topic-conversations.js +107 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 工作员共同准则
|
|
2
|
+
|
|
3
|
+
你是主老师或首页总教练在幕后派出的专业工作员,在独立隔离的上下文中专注完成本次交办的单一实证任务。
|
|
4
|
+
|
|
5
|
+
## 1. 实证优先与工具边界
|
|
6
|
+
|
|
7
|
+
- **严格区分事实与推测**:基于交付的目标、材料与原页开展工作;将原文条件、标准答案、学生实际作答与主老师的初步猜想区分清楚。
|
|
8
|
+
- **确定性验算与权威寻源**:
|
|
9
|
+
- 凡涉及数值计算、代数推导、极限/对数验算或代码运行结果,拥有 `bash` 权限时必须用 Python/SymPy 脚本实测验算,绝不凭印象口算;
|
|
10
|
+
- 本地缺少关键资料或权威定义时,主动用 `web_search` / `web_fetch` 抓取公开权威来源并注明真实 URL。
|
|
11
|
+
|
|
12
|
+
## 2. 撰写或核查卡片草案时的准则
|
|
13
|
+
|
|
14
|
+
起草 Markdown 资料(卡片、导读、模板等)时,草案的 YAML Frontmatter 同步提供 `summary` 字符串,由主老师与正文一起保存。用约 30~60 字的一句纯文本说明主题与用途,不含公式、代码、HTML 或双链。题卡仅介绍对象、题设条件与目标,不泄露答案、突破口或提示;不写学生掌握程度。内容范围改变才同步修订摘要,保留仍准确的手写摘要,不批量补写旧资料,也不为非 Markdown 文件强加此字段。
|
|
15
|
+
|
|
16
|
+
当任务要求起草或核查知识卡片时,严格遵守三段式结构(`## 内容` / `## 参考理解` / `## 学生理解`)与「横向一卡一事、纵向推导不跳步」:
|
|
17
|
+
1. **`## 内容`(10~20 行)**:保留独立完整的单一概念追问、最小代码现场或单一题面;底部 `<details><summary>查看原页</summary>` 收纳 `![[来源/...]]` 或 URL。
|
|
18
|
+
2. **`## 参考理解`(图谱虚线)**:开头给 1~2 句加粗的**核心主干路标**,下方从学习者已有的知识储备出发,一步不跳地展开完整推导(首次出现的术语/符号当场从已知储备补清来历,采用严谨客观的教材说明文体,禁“不是 X 而是 Y”假对比);关联卡片写 `[[双链]]`;尚未全面证实的猜想放入末尾 `### 下次探讨`(1~2 条)。
|
|
19
|
+
3. **`## 学生理解`(学生表达与当前联系)**:仅整理任务中实际交付的学习者真实表达与费曼总结,无学生表达时留空。
|
|
20
|
+
|
|
21
|
+
## 3. 干净交付
|
|
22
|
+
|
|
23
|
+
直接交付完整、可核查的任务产物,并在末尾用 2~3 句话交代核心结论、验算依据与待核对项。
|
|
24
|
+
|
|
25
|
+
- **当前知识联系格式**:在 `## 学生理解` 下,用 `### 已建立的联系` 的直接列表记录当前成立的联系,每项以 `- [[卡片/另一知识卡]]:` 开头,后接学生实际说明的关系、条件或理由,并如实注明提示与来源。一项只声明开头那张知识卡,其余链接是引用。经过提示后真正形成的联系同样保留,不要求无提示成功或复习升级;老师单方解释、照抄和未能解释不登记为已建立。
|
|
26
|
+
- 未建立或后来撤回的联系写入 `### 待探索的联系`,使用同样的“目标卡片:当前疑问/更正原因”列表。这一列表表示整条联系尚未成立或已撤回;已经成立的联系仍有后续问题时,将问题放 `### 下次探讨`,不要因此取消已有联系。更正时核对两端当前列表,撤掉旧的已建立项,原话和经过可移到折叠演进记录;任一端仍有待探索记录时不画实线。来源、日记、普通叙述及折叠历史链接不声明当前知识联系。已有成熟个人笔记可按实际证据整理到当前列表,不强制重教或考试,也不按旧实线批量推定成立。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 出题员
|
|
2
|
+
|
|
3
|
+
根据指定知识点或母题设计靶向微练习、对比题组或结构性变式,并完成自解核验后交付。
|
|
4
|
+
|
|
5
|
+
- **明确变式设计意图**:按目标选择基础巩固(换数练熟)、条件变式(改动关键条件检验边界意识)、对比题组(外观仅差一处特征导致解法分流)或易错触发题。说明保留了什么结构、改动了哪一处、准备检验学生的哪个判断。
|
|
6
|
+
- **先自解再交付**:每道拟题必须先从头完整解出,确认条件充分、无歧义、答案准确。
|
|
7
|
+
- **学生版与教师参考分离**:正文只放完整独立的题面;完整解答、递进提示与诊断建议放在随后单独成行的 `<details data-notara="teacher">` / `<summary>教师参考</summary>` / `</details>` 折叠块内。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 通用工作员
|
|
2
|
+
|
|
3
|
+
在给定范围内完成资料检索、证据比对或单个知识单元的拆解研究。
|
|
4
|
+
|
|
5
|
+
- **检索与证据整理(本地 + 公开网页寻源)**:先根据当前学习集文件正文实质内容比对相关性,附上真实文件路径、页码/区域定位与召回依据;**若学习集内找不到合适的资料,主动使用 `web_search` 与 `web_fetch` 搜索并抓取公开权威网页资料**,汇总核心内容、来源 URL、条件差异与待补缺口。
|
|
6
|
+
- **知识单元与延伸命题隔离研究**:讲清该概念或方法要解决什么问题、定义与成立条件、核心推演、典型例证及适用边界;当主老师委托对某一跨案例概括或延伸命题做隔离研究时,脱离单一案例的局部惯性,专门构造边界情形与简单反例检验该命题是否过强,给出精确成立前提、失效反例与修正后的严谨表述。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 学习路径与备课员
|
|
2
|
+
|
|
3
|
+
将主老师交付的目标、学习者画像与材料组织成结构清晰的 Markdown 导读、任务路径、体裁模板(`模板/<名称>模板.md`)或备课草稿。
|
|
4
|
+
|
|
5
|
+
- **按「目的光谱 × 水平梯度」与“立基准 → 抓边界 → 换视角”编排导读与路径**:区分学科应试、技能实践与兴趣通识三类目的及学习者当前梯度(破冰起步期 / 独立破局期 / 融通精进期);原教材导读保持原有章节顺序并讲透相邻内容之间的暗线(哪处是基准模型、哪处改变了基准边界、哪处切换了视角);专题任务路径围绕小目标组织(含核心问题与边界聚焦、带真实双链和 `- [ ]` 的跨源卡片任务包、教学目标、留空的本节足迹与暂停点)。若库内尚无对应大题型/体裁模板且任务要求备课建库,可先结合真实样本按「元模板五问」拟定 `模板/<名称>模板.md`。
|
|
6
|
+
- **教师参考格式**:需要附备课参考时,就近放在对应段落后,严格使用单独成行的 `<details data-notara="teacher">`、`<summary>教师参考</summary>` 与 `</details>`,内含完整解答、递进提示与观察点。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 题目与案例研究员
|
|
2
|
+
|
|
3
|
+
专注攻克本次指定的题目、技能实战案例或长材料中的局部设问,交付完整可核查的解题/实操分析或可直接保存的卡片草稿。
|
|
4
|
+
|
|
5
|
+
- **核对题面、案例现场与对应体裁模板**:对照交付文本与原页图像,确认条件、小问、选项、代码/配置、图形、单位齐全,并留意原讲义底部或旁批是否印有指定的 `基本方法` 或留白【总结】栏;若学习集内有对应体裁的 `模板/<名称>模板.md`(如《实验题模板》《计算题模板》《语言特性模板》),照其关注要素与高频漏检项逐一核查;长材料的局部设问带足前后文语境并回链完整来源。
|
|
6
|
+
- **抓关键线索、实操排错与方法边界**:在参考理解中避免平铺直叙抄步骤,重点写清“初看哪里受阻或实际痛点在哪(什么改变了基准结构) → 如何从原文、条件或报错信息中找出关键线索(① 排查尚未用上的限定条件或局部信息;② 对比与基准模型反常的差异处;③ 寻找同时关联已知与目标的中间结构或转折路标;④ 用极端特例或最小可运行示例 MVP 试探) → 怎样走通推演或实现路径 → 改变什么条件或需求会触及失效与权衡边界(Trade-off)”,并把原讲义留白的【总结】(如动机、基本方法、关键技巧)完整填满。
|
|
7
|
+
- **有据可依与首现自检**:首次出现的专业术语、定理或 API 当场用一句话补清来历;语篇与人文题串起跨句搭配、指代与材料实证;实验与图表题对齐自变量、中间过程与因变量;代码与计算题可在授权时通过 `bash` 跑脚本做确定性验算;若发现参考答案与推演矛盾,保留原文并明确列出矛盾点供主老师复核。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 核验员
|
|
2
|
+
|
|
3
|
+
针对本次 `focus` 指定的具体疑点、推导步骤或卡片内容进行独立核查。
|
|
4
|
+
|
|
5
|
+
- **对照原页、逻辑链与延伸命题核验**:逐项检查概念范围、规则适用前提、推演等价性、边界条件及原页图文一致性;对主老师拟写入卡片的跨案例概括或延伸命题,在隔离上下文中专门寻找简单反例或退化边界进行压力测试(防止把局部无解或需满足相容条件夸大为普遍互斥),区分“已证实结论”与“待检验猜想”;区分“原材料本身的印刷/答案瑕疵”与“整理草稿引入的错误”。
|
|
6
|
+
- **清晰结论格式**:按「结论(成立 / 需修改 / 证据不足)— 具体依据 — 最小修改建议」简洁交付;核验无误直接确认通过。
|
package/resources.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const root = new URL('./resources/', import.meta.url);
|
|
5
|
+
const provenance = JSON.parse(readFileSync(new URL('provenance.json', root), 'utf8'));
|
|
6
|
+
|
|
7
|
+
// Eager, verified package resources: a missing core prompt prevents loading the
|
|
8
|
+
// plugin instead of silently sending a generic fallback prompt to the model.
|
|
9
|
+
const bundled = Object.freeze(Object.fromEntries(Object.entries(provenance.sha256).map(([path, hash]) => {
|
|
10
|
+
const bytes = readFileSync(new URL(path, root));
|
|
11
|
+
if (createHash('sha256').update(bytes).digest('hex') !== hash) {
|
|
12
|
+
throw new Error(`Bundled Notara resource checksum mismatch: ${path}`);
|
|
13
|
+
}
|
|
14
|
+
return [path, bytes.toString('utf8')];
|
|
15
|
+
})));
|
|
16
|
+
|
|
17
|
+
export const skillsDirectory = new URL('pi-skills-built/', root);
|
|
18
|
+
export const resourceProvenance = Object.freeze(provenance);
|
|
19
|
+
export function resource(path) {
|
|
20
|
+
if (!Object.hasOwn(bundled, path)) throw new Error(`Unknown bundled Notara resource: ${path}`);
|
|
21
|
+
return bundled[path];
|
|
22
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const CONTEXT_NAME = 'notara-review-confirmations';
|
|
2
|
+
const context = text => ({ name: CONTEXT_NAME, text, interpolate: false });
|
|
3
|
+
const unavailable = () => context('本轮未能读取当前会话的复习确认状态;此前状态快照可能已过时。不要把老师建议、旧快照或读取失败当成学生已确认;需要时重新读取原卡核对。');
|
|
4
|
+
const clip = (value, limit) => typeof value === 'string' ? value.slice(0, limit) : '';
|
|
5
|
+
|
|
6
|
+
/** A read-only projection at native prompt assembly. No new messages, ledger,
|
|
7
|
+
* watchers or model wakeups; every receipt comes from the original card. */
|
|
8
|
+
export async function readReviewContext(ctx, { agent, scope, signal }) {
|
|
9
|
+
if (!agent || scope !== agent) return null;
|
|
10
|
+
const vault = ctx.get('notaraVault'), registry = ctx.get('workspaceRegistry'), query = ctx.get('sessionQuery');
|
|
11
|
+
if (!vault || !registry || !query || !agent.session.header.cwd) return null;
|
|
12
|
+
try {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const workspace = await registry.resolveByPath(agent.session.header.cwd);
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
if (!workspace) return unavailable();
|
|
17
|
+
const snapshot = await query.readSurface(agent.id);
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
if (snapshot.session.id !== agent.id || snapshot.session.cwd !== agent.session.header.cwd) return unavailable();
|
|
20
|
+
const [{ conversationReviewProposals }, { parseMarkdown }, { isCardType, REVIEW_SUPPORT_LABELS }] = await Promise.all([
|
|
21
|
+
import('@oh-my-student/plugin-graph-and-blocks/review-proposals'),
|
|
22
|
+
import('@oh-my-student/plugin-vault-sqlite/frontmatter'),
|
|
23
|
+
import('@oh-my-student/plugin-vault-sqlite/review-contract'),
|
|
24
|
+
]);
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
const proposals = snapshot.events.filter(event => event.type === 'assistant/message' && event.seq >= snapshot.inheritedEventCount).slice(-40).flatMap(event => {
|
|
27
|
+
const message = event.data.message;
|
|
28
|
+
const text = message.content.filter(block => block.type === 'text').map(block => block.text).join('');
|
|
29
|
+
return conversationReviewProposals(text).map(proposal => ({ ...proposal, messageId: message.id }));
|
|
30
|
+
});
|
|
31
|
+
const selected = proposals.slice(-16), documents = new Map(), receipts = [];
|
|
32
|
+
for (const proposal of selected) {
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
if (!documents.has(proposal.targetPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const doc = await vault.read({ workspaceId: workspace.id, path: proposal.targetPath });
|
|
37
|
+
const fields = parseMarkdown(doc.content).fields;
|
|
38
|
+
if (!isCardType(fields.type) || fields.review_history !== undefined && (!Array.isArray(fields.review_history) || fields.review_history.some(row => !row || typeof row !== 'object' || Array.isArray(row)))) throw new Error('invalid_review_history');
|
|
39
|
+
documents.set(proposal.targetPath, fields.review_history ?? []);
|
|
40
|
+
} catch { documents.set(proposal.targetPath, null); }
|
|
41
|
+
}
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
const history = documents.get(proposal.targetPath);
|
|
44
|
+
const matching = history?.filter(row => row.source?.messageId === proposal.messageId && row.source?.index === proposal.index);
|
|
45
|
+
const row = matching?.length === 1 ? matching[0] : null;
|
|
46
|
+
const receipt = { ...proposal, status: !history || matching.length > 1 ? '读取失败或记录不明确' : !row ? '尚未找到确认记录' : row.revertedAt ? '已撤销' : '已记录' };
|
|
47
|
+
if (row) {
|
|
48
|
+
if (!['done', 'missed', 'unchecked'].includes(row.result) ||
|
|
49
|
+
row.support !== undefined && !Object.hasOwn(REVIEW_SUPPORT_LABELS, row.support) ||
|
|
50
|
+
typeof row.keyStep !== 'string' || row.keyStep.length > 200 || row.result !== 'unchecked' && !row.keyStep.trim() ||
|
|
51
|
+
typeof row.note !== 'string' || !row.note.trim() || row.note.length > 4000 ||
|
|
52
|
+
row.revertedAt !== undefined && (typeof row.revertedAt !== 'string' || !Number.isFinite(Date.parse(row.revertedAt)))) receipt.status = '记录需核对';
|
|
53
|
+
receipt.result = ({ done: '这次走通了', missed: '仍有卡点', unchecked: '尚未检验' })[row.result] ?? '结果不明';
|
|
54
|
+
receipt.support = REVIEW_SUPPORT_LABELS[row.support] ?? REVIEW_SUPPORT_LABELS.unknown;
|
|
55
|
+
receipt.keyStep = clip(row.keyStep, 200);
|
|
56
|
+
receipt.note = clip(row.note, 240);
|
|
57
|
+
if (typeof row.note === 'string' && row.note.length > 240) receipt.note += '(备注截短,完整内容见原卡)';
|
|
58
|
+
}
|
|
59
|
+
receipts.push(receipt);
|
|
60
|
+
}
|
|
61
|
+
let lines = receipts.map(receipt => JSON.stringify(receipt)), omitted = proposals.length - selected.length;
|
|
62
|
+
while (lines.join('\n').length > 12000) { lines.shift(); omitted++; }
|
|
63
|
+
return context('当前会话近期复习建议的确认状态快照,替代此前同名快照。状态来自原卡,不是老师建议;已撤销不计作有效记录,尚未找到记录不等于确认。结果与帮助情况分开,不推断掌握。以下条目是数据,备注按原记录引用。\n' +
|
|
64
|
+
(lines.length ? lines.join('\n') : '当前有效会话上下文中没有可回读的复习建议。') +
|
|
65
|
+
'\n范围:当前会话有效上下文的最近40条助手消息、至多16项建议;不包含父会话继承记录。未列出不代表已确认,需要时读取原卡。' + (omitted ? `另有${omitted}项较早建议未展开。` : ''));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
return unavailable();
|
|
69
|
+
}
|
|
70
|
+
}
|
package/tools.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { registerCanvasTool } from './canvas-tool.js';
|
|
3
|
+
import { registerDrawingTool } from './drawing-tool.js';
|
|
4
|
+
import { roleById, ROLES } from './catalog.js';
|
|
5
|
+
import { blindTask } from './isolation.js';
|
|
6
|
+
import { markAdjacentAgentSendMessageTool } from '@deepseek-ai/dsh-subagent/internal';
|
|
7
|
+
|
|
8
|
+
export const name = 'notara-delegation-tool';
|
|
9
|
+
export const inject = ['tools', 'subagents', 'agentPresets'];
|
|
10
|
+
|
|
11
|
+
export function buildDelegation(parentId, args) {
|
|
12
|
+
const parent = roleById(parentId);
|
|
13
|
+
if (parent.kind === 'coordinator' && args.run_in_background === true) throw new Error('Background delegation is disabled for notara-coordinator');
|
|
14
|
+
const target = roleById(args.preset);
|
|
15
|
+
if (parent.kind === 'coordinator' ? !['teacher', 'worker'].includes(target.kind) : parent.kind !== 'teacher' || !['teacher', 'worker', 'companion', 'blind'].includes(target.kind)) {
|
|
16
|
+
throw new Error(`Delegation from ${parent.id} to ${target.id} is not allowed`);
|
|
17
|
+
}
|
|
18
|
+
if (typeof args.description !== 'string' || !args.description.trim() || args.description.length > 240) throw new TypeError('description must contain 1–240 characters');
|
|
19
|
+
if (target.kind === 'blind') {
|
|
20
|
+
if (args.prompt !== undefined) throw new TypeError('Blind delegation rejects generic prompt/context; use problem and studentExplanation');
|
|
21
|
+
return { target, prompt: blindTask({ problem: args.problem, studentExplanation: args.studentExplanation }) };
|
|
22
|
+
}
|
|
23
|
+
if (args.problem !== undefined || args.studentExplanation !== undefined) throw new TypeError('Blind fields require companion-deepseek-blind');
|
|
24
|
+
if (typeof args.prompt !== 'string' || !args.prompt.trim() || args.prompt.length > 96_000) throw new TypeError('prompt must contain 1–96000 characters');
|
|
25
|
+
return { target, prompt: args.prompt };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function apply(ctx, { preset }) {
|
|
29
|
+
const parentRole = roleById(preset);
|
|
30
|
+
registerSendMessage(ctx, preset);
|
|
31
|
+
if(parentRole.kind==='teacher')ctx.inject(['notaraVault','workspaceRegistry'],runtimeCtx=>{registerCanvasTool(runtimeCtx,preset);registerDrawingTool(runtimeCtx,preset);});
|
|
32
|
+
if (!['teacher', 'coordinator'].includes(parentRole.kind)) return;
|
|
33
|
+
if(parentRole.kind==='teacher')ctx.tools.register(defineTool({
|
|
34
|
+
name:'interrupt_agent',description:'请求中断当前运行的原生子会话,保留历史以便继续。仅在用户要求停止或任务确需中止时使用;普通后台等待不需要中断。不能用 job_kill 停止子会话。',
|
|
35
|
+
parameters:{agent_id:{type:'string',required:true}},
|
|
36
|
+
output:{schema:{type:'object',additionalProperties:false,properties:{requested:{type:'boolean',required:true}}},render:()=>[{type:'text',text:'已请求中断;完成状态以原生会话通知为准。'}]},
|
|
37
|
+
execute(args,exec){if(!exec.agent||ctx.agentPresets.composedPreset(exec.agent.ctx)!==preset)throw new Error('需要当前课堂身份');exec.signal.throwIfAborted();ctx.subagents.interrupt(args.agent_id,{kind:'ancestor',agent:exec.agent});return {requested:true};},
|
|
38
|
+
}));
|
|
39
|
+
const backgroundEnabled = parentRole.kind === 'teacher';
|
|
40
|
+
const targets = ROLES.filter(role => parentRole.kind === 'coordinator' ? ['teacher', 'worker'].includes(role.kind) : ['teacher', 'worker', 'companion', 'blind'].includes(role.kind));
|
|
41
|
+
ctx.tools.register(defineTool({
|
|
42
|
+
name: 'subagent',
|
|
43
|
+
description: '委派到指定角色,不继承当前对话历史。首页调用 notara-teacher 创建普通独立专题;老师调用 notara-teacher 或 companion-* 创建可继续交流的原生子会话,立即返回入口。只有创建成功才可邀请学生进入;文字 @伙伴 不会启动伙伴。worker-* 和盲测默认等待本轮结果,run_in_background 可让工作员在后台执行。原生运行通知不等于完课或掌握。盲测仅交付原始题面和学生本次讲解,禁止包含答案或参考理解。',
|
|
44
|
+
parameters: {
|
|
45
|
+
preset: { type: 'string', enum: targets.map(role => role.id), required: true },
|
|
46
|
+
description: { type: 'string', required: true },
|
|
47
|
+
prompt: { type: 'string', description: '完整独立交接单;盲测禁止传入。' },
|
|
48
|
+
problem: { type: 'string', description: '仅盲测:不含解析的原始题面。' },
|
|
49
|
+
studentExplanation: { type: 'string', description: '仅盲测:学生本次真实讲解。' },
|
|
50
|
+
...backgroundEnabled ? { run_in_background: { type: 'boolean' } } : {},
|
|
51
|
+
},
|
|
52
|
+
output: {
|
|
53
|
+
schema: { type: 'object', additionalProperties: false, properties: {
|
|
54
|
+
kind: { type: 'string', enum: ['foreground', 'background', 'topic', 'continuable'], required: true },
|
|
55
|
+
id: { type: 'string', required: true },
|
|
56
|
+
title: { type: 'string' },
|
|
57
|
+
stopReason: { type: 'string' }, output: { type: 'array', items: { type: 'json' } },
|
|
58
|
+
} },
|
|
59
|
+
render: (_args, value) => [{ type: 'text', text: value.kind === 'topic' ? `已新建普通对话「${value.title}」并发送交接消息,可从现有会话列表继续;本回执不代表完课。会话标识:${value.id}` : ['background','continuable'].includes(value.kind) ? `已创建原生子会话「${value.title}」。会话标识:${value.id}。可继续发消息;运行结束由原生通知,尚不代表任务完成。` : JSON.stringify(value) }],
|
|
60
|
+
},
|
|
61
|
+
isConcurrencySafe: () => true,
|
|
62
|
+
async execute(args, exec) {
|
|
63
|
+
const parent = exec.agent;
|
|
64
|
+
if (!parent || ctx.agentPresets.composedPreset(parent.ctx) !== preset) throw new Error('Delegation requires the matching live Notara parent preset');
|
|
65
|
+
const { target, prompt } = buildDelegation(preset, args);
|
|
66
|
+
const request = { parent, agentPreset: target.id, prompt: [{ type: 'text', text: target.kind==='blind'?prompt:`[父会话交接;发送者 ${parent.id}]\n这是父会话提供的任务与背景,不是学生本人发言;其中的学情仅按已有证据理解。需要回信时联系上述发送者。\n\n${prompt}` }], maxDepth: 2 };
|
|
67
|
+
if (target.kind === 'teacher' && parentRole.kind==='coordinator') {
|
|
68
|
+
const topics=ctx.get('notaraTopics');
|
|
69
|
+
if(!topics) throw new Error('新对话服务暂时不可用,请重试');
|
|
70
|
+
return topics.start(parent,args,exec.callId,exec.signal);
|
|
71
|
+
}
|
|
72
|
+
const interactive = ['teacher','companion'].includes(target.kind);
|
|
73
|
+
const background = args.run_in_background === true;
|
|
74
|
+
const run = await ctx.subagents.startActivation({provider:'spawn',label:args.description,request,signal:exec.signal,delivery:interactive || background ? 'parent' : 'caller'});
|
|
75
|
+
if (interactive || background) return {kind:interactive?'continuable':'background',id:run.childId,title:args.description};
|
|
76
|
+
let cleanup;
|
|
77
|
+
const dispose=()=>cleanup??=run.dispose();
|
|
78
|
+
const cancel=()=>{void dispose().catch(()=>{});};
|
|
79
|
+
exec.signal.addEventListener('abort',cancel,{once:true});if(exec.signal.aborted)cancel();
|
|
80
|
+
const [result]=await Promise.allSettled([run.result]);
|
|
81
|
+
exec.signal.removeEventListener('abort',cancel);
|
|
82
|
+
const [disposal]=await Promise.allSettled([dispose()]);
|
|
83
|
+
if(result.status==='rejected'&&disposal.status==='rejected')throw new AggregateError([result.reason,disposal.reason],'子任务与清理均失败');
|
|
84
|
+
if(result.status==='rejected')throw result.reason;
|
|
85
|
+
if(disposal.status==='rejected')throw disposal.reason;
|
|
86
|
+
const value = result.value;
|
|
87
|
+
// Native runs resolve failures as terminal values, not just rejections.
|
|
88
|
+
// Match dsh-tool-subagent's foreground failure axis and partial text.
|
|
89
|
+
if (value.stopReason !== 'completed') {
|
|
90
|
+
const headline = { aborted: 'subagent run was cancelled', error: 'subagent run failed', 'max-tokens': 'subagent run hit its token limit before finishing', refusal: 'subagent declined the task' }[value.stopReason] ?? `subagent run ended abnormally (${String(value.stopReason)})`;
|
|
91
|
+
const diagnostic = value.diagnostic === undefined ? '' : `\nDiagnostic: ${value.diagnostic}`;
|
|
92
|
+
const partial = value.output.filter(block => block.type === 'text').map(block => block.text).join('');
|
|
93
|
+
throw new Error(headline + diagnostic + (partial ? `\nPartial output before the run ended:\n${partial}` : ''));
|
|
94
|
+
}
|
|
95
|
+
return { kind: 'foreground', id: run.childId, stopReason: value.stopReason, output: value.output };
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function registerSendMessage(ctx, preset) {
|
|
101
|
+
ctx.tools.register(markAdjacentAgentSendMessageTool(defineTool({
|
|
102
|
+
name:'send_message',description:'按需给相邻会话发消息。原生子智能体可联系直接父会话或子会话;独立专题可联系创建它的会话,首页可联系自己创建的专题。收到会话来信后,若决定回复,必须用此工具发给发送者;当前窗口的普通回复不会送达。告知类消息无需自动致谢或循环回信。返回值仅表示已投递,不表示对方回答或任务完成。',
|
|
103
|
+
parameters:{agent_id:{type:'string',required:true,description:'创建回执或交接来源中的会话标识。'},message:{type:'string',required:true,description:'要发送的内容,按需要说明进展、问题或补充信息。'}},
|
|
104
|
+
output:{schema:{type:'object',additionalProperties:false,properties:{messageId:{type:'string',required:true}}},render:()=>[{type:'text',text:'消息已投递。'}]},
|
|
105
|
+
async execute(args,exec){
|
|
106
|
+
const sender=exec.agent;
|
|
107
|
+
if(!sender||ctx.agentPresets.composedPreset(sender.ctx)!==preset)throw new Error('需要当前会话身份');
|
|
108
|
+
if(!args.message.trim()||args.message.length>12000)throw new Error('消息须为1–12000字');
|
|
109
|
+
const target=ctx.get('agents')?.get(args.agent_id);
|
|
110
|
+
const snapshot=target?{session:target.session.header,events:target.session.snapshotEvents()}:await ctx.get('sessionQuery')?.readSession(args.agent_id);
|
|
111
|
+
const targetPreset=snapshot?.events.filter(e=>e.type==='agent-preset/selected').at(-1)?.data.agentPreset??snapshot?.session.agentPreset;
|
|
112
|
+
if(targetPreset==='companion-deepseek-blind')throw new Error('盲测不接受通用追加上下文;重新提交原题和学生本次讲解。');
|
|
113
|
+
const topics=ctx.get('notaraTopics');
|
|
114
|
+
if(topics && (await topics.origin(sender.id)===args.agent_id||await topics.origin(args.agent_id)===sender.id))return topics.send(sender,args.agent_id,args.message,exec.callId,exec.signal);
|
|
115
|
+
return {messageId:await ctx.subagents.sendMessage(sender,args.agent_id,[{type:'text',text:args.message}],{signal:exec.signal})};
|
|
116
|
+
},
|
|
117
|
+
})));
|
|
118
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {Service} from '@deepseek-ai/cordis';
|
|
3
|
+
import {createUserMessage} from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import {appendDelegatedPolicyOverrides,captureDelegatedPolicyOverrides,resolveChildAgentOptions} from '@deepseek-ai/dsh-subagent';
|
|
5
|
+
|
|
6
|
+
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const handoffSource='notara-topic-handoff';
|
|
8
|
+
function messagesOf(snapshot){
|
|
9
|
+
const messages=snapshot.events.filter(e=>e.type==='user/message').map(e=>e.data);
|
|
10
|
+
const pending={'next-turn':[],'next-step':[]},claimed=[];
|
|
11
|
+
for(const event of snapshot.events){
|
|
12
|
+
if(event.type!=='agent/inbox/spliced')continue;
|
|
13
|
+
const {target,start,removedCount=0,inserted,outcome}=event.data;
|
|
14
|
+
const removed=pending[target]?.splice(start,removedCount,...inserted)??[];
|
|
15
|
+
// Claim precedes async prompt assembly and user/message publication. A
|
|
16
|
+
// retry during that gap must not resurrect accepted work. Queue edits and
|
|
17
|
+
// explicit cancellations are not claims.
|
|
18
|
+
if(!inserted.length&&outcome!=='canceled')claimed.push(...removed);
|
|
19
|
+
}
|
|
20
|
+
return [...messages,...claimed,...pending['next-turn'],...pending['next-step']];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Creates ordinary native conversations. No teaching entity, progress ledger, or second history. */
|
|
24
|
+
export class TopicConversations extends Service{
|
|
25
|
+
static inject=['agents','sessions','sessionQuery','sessionPersistence','agentPresets'];
|
|
26
|
+
constructor(ctx){
|
|
27
|
+
super(ctx,'notaraTopics');this.pending=new Map();this.handles=new Map();
|
|
28
|
+
// Service.ctx is caller-traced by Cordis; retain the actual host owner.
|
|
29
|
+
this.native={
|
|
30
|
+
create:options=>ctx.agents.withoutInitiator(()=>ctx.agents.create(options)),
|
|
31
|
+
resume:options=>ctx.agents.withoutInitiator(()=>ctx.agents.resume(options)),
|
|
32
|
+
controller:()=>ctx.get('sessionController'),
|
|
33
|
+
resolve:(controller,id)=>ctx.agents.withoutInitiator(()=>controller.resolveAgent(id)),
|
|
34
|
+
attach:async(cwd,id)=>{const workspace=await ctx.get('workspaceRegistry')?.resolveByPath(cwd);if(workspace)await ctx.agents.withoutInitiator(()=>workspace.attachSession(id));},
|
|
35
|
+
};
|
|
36
|
+
ctx.effect(()=>async()=>{await Promise.allSettled([...this.pending.values()].map(row=>row.promise));await Promise.allSettled([...this.handles.values()].map(handle=>handle.dispose()));});
|
|
37
|
+
}
|
|
38
|
+
assertSender(agent,signal){signal.throwIfAborted();if(this.ctx.agents.get(agent.id)!==agent)throw new Error('发送会话已关闭');}
|
|
39
|
+
async exclusive(key,fingerprint,run){
|
|
40
|
+
const prior=this.pending.get(key);if(prior){if(prior.fingerprint!==fingerprint)throw new Error('同一请求携带了不同内容,不能合并');return prior.promise;}
|
|
41
|
+
const promise=run();this.pending.set(key,{fingerprint,promise});try{return await promise;}finally{this.pending.delete(key);}
|
|
42
|
+
}
|
|
43
|
+
read(id){return this.ctx.sessionQuery.readSession(id);}
|
|
44
|
+
async origin(id){const snapshot=await this.read(id);return messagesOf(snapshot).map(message=>message.source).find(source=>source?.kind===handoffSource)?.senderSessionId??null;}
|
|
45
|
+
async resume(id){
|
|
46
|
+
const live=this.ctx.agents.get(id);if(live)return live;
|
|
47
|
+
const controller=this.native.controller();
|
|
48
|
+
if(controller){const value=await this.native.resolve(controller,id);if(value.error)throw new Error(value.error.message);return value.agent;}
|
|
49
|
+
const {session,events}=await this.read(id);
|
|
50
|
+
const config=events.filter(e=>e.type==='request/header').at(-1)?.data.header.config;
|
|
51
|
+
const picked=events.filter(e=>e.type==='model/selection').at(-1)?.data??config;
|
|
52
|
+
const preset=events.filter(e=>e.type==='agent-preset/selected').at(-1)?.data.agentPreset??session.agentPreset;
|
|
53
|
+
const handle=await this.native.resume({resumeSessionId:id,...picked?{agentOptions:{provider:picked.provider,model:picked.model,...picked.reasoningEffort?{reasoningEffort:picked.reasoningEffort}:{}}}:{},setup:async childCtx=>{if(preset)await childCtx.get('agentPresets').mount(childCtx,preset);}});
|
|
54
|
+
this.handles.set(id,handle);return handle.agent;
|
|
55
|
+
}
|
|
56
|
+
async release(id){const handle=this.handles.get(id);if(handle){await handle.dispose();this.handles.delete(id);}}
|
|
57
|
+
async start(parent,args,key,signal){
|
|
58
|
+
const id=`notara-topic-${hash(JSON.stringify([parent.id,key])).slice(0,32)}`;
|
|
59
|
+
const fingerprint=hash(JSON.stringify([args.description,args.prompt]));
|
|
60
|
+
this.assertSender(parent,signal);
|
|
61
|
+
return this.exclusive(id,fingerprint,async()=>{
|
|
62
|
+
let agent=this.ctx.agents.get(id);
|
|
63
|
+
if(!agent){
|
|
64
|
+
if(await this.ctx.sessionPersistence.stat(id))agent=await this.resume(id);
|
|
65
|
+
else{
|
|
66
|
+
const policy=captureDelegatedPolicyOverrides(parent);
|
|
67
|
+
const handle=await this.native.create({sessionId:id,meta:{cwd:parent.session.header.cwd,agentPreset:'notara-teacher'},agentOptions:resolveChildAgentOptions(parent,undefined,0),signal,
|
|
68
|
+
setup:async(childCtx,child)=>{await childCtx.get('agentPresets').mount(childCtx,'notara-teacher');appendDelegatedPolicyOverrides(child.session,policy);},
|
|
69
|
+
});
|
|
70
|
+
this.handles.set(id,handle);agent=handle.agent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if(agent.session.header.cwd!==parent.session.header.cwd||agent.session.header.origin==='subagent')throw new Error('专题会话归属不一致');
|
|
74
|
+
if(!await this.ctx.sessions.flush(agent.session))throw new Error('新对话尚未保存,请重试');
|
|
75
|
+
await this.native.attach(parent.session.header.cwd,id);
|
|
76
|
+
const snapshot=await this.read(id);
|
|
77
|
+
const prior=messagesOf(snapshot).map(message=>message.source).find(source=>source?.kind===handoffSource);
|
|
78
|
+
if(prior&&(prior.senderSessionId!==parent.id||prior.fingerprint!==fingerprint))throw new Error('该请求已经用于另一份交接,请重试原请求');
|
|
79
|
+
if(!prior){
|
|
80
|
+
const controller=this.native.controller();if(controller)await controller.rename({sessionId:id,title:args.description});
|
|
81
|
+
this.assertSender(parent,signal);
|
|
82
|
+
agent.followup(createUserMessage({content:[{type:'text',text:`[专题交接;来源会话 ${parent.id}]\n这是发起会话提供的背景,不是学习者本人的发言。只把有学生原话或实际作答支持的内容视作已确认前置。\n\n${args.prompt}`}],source:{kind:handoffSource,form:'relay',senderSessionId:parent.id,fingerprint}}));
|
|
83
|
+
if(!await this.ctx.sessions.flush(agent.session))throw new Error('交接消息尚未保存,请重试');
|
|
84
|
+
}
|
|
85
|
+
return {kind:'topic',id,title:args.description};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Optional ordinary message between the two conversations linked by their native handoff. */
|
|
89
|
+
async send(sender,targetId,message,key,signal){
|
|
90
|
+
this.assertSender(sender,signal);
|
|
91
|
+
const rpcId=`notara-relay:${hash(JSON.stringify([sender.id,key]))}`,fingerprint=hash(JSON.stringify([targetId,message]));
|
|
92
|
+
return this.exclusive(rpcId,fingerprint,async()=>{
|
|
93
|
+
const [origin,targetOrigin]=await Promise.all([this.origin(sender.id),this.origin(targetId)]);
|
|
94
|
+
if(origin!==targetId&&targetOrigin!==sender.id)throw new Error('只能联系创建本对话的会话或由本对话创建的专题');
|
|
95
|
+
const targetState=await this.read(targetId);
|
|
96
|
+
if(targetState.session.cwd!==sender.session.header.cwd||targetState.session.origin==='subagent')throw new Error('目标会话不属于同一工作区');
|
|
97
|
+
const target=await this.resume(targetId);
|
|
98
|
+
const existing=messagesOf(await this.read(targetId)).find(item=>item.source?.rpcId===rpcId);
|
|
99
|
+
if(existing){if(existing.source.fingerprint!==fingerprint)throw new Error('同一投递请求的消息内容不同');return {messageId:existing.id};}
|
|
100
|
+
this.assertSender(sender,signal);
|
|
101
|
+
const value=createUserMessage({content:[{type:'text',text:`[会话来信;发送者 ${sender.id}]\n这是另一会话的消息,不是学生发言。若决定回复,请用 send_message(agent_id:"${sender.id}",message:"回复内容");写在当前窗口不会送达。告知无需自动回信;投递成功不代表对方已回答。\n\n${message}`}],source:{kind:'agent-message',form:'relay',senderSessionId:sender.id,rpcId,fingerprint}});
|
|
102
|
+
target.followup(value);
|
|
103
|
+
if(!await this.ctx.sessions.flush(target.session))throw new Error('消息尚未保存,请重试原请求');
|
|
104
|
+
return {messageId:value.id};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|