@x-otto/cli 0.0.1-alpha.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/README.md +120 -0
- package/bin/otto.js +61 -0
- package/dist/auth-command-CXAB3_hs.js +11 -0
- package/dist/auth-command-CXAB3_hs.js.map +1 -0
- package/dist/build-id-BX4QHzM0.js +3 -0
- package/dist/build-id-BX4QHzM0.js.map +1 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/otto-plugin.json +8 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/plugin.cjs +60023 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin.ts +64 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-messages.ts +916 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-usage.ts +81 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-completions.ts +158 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-responses.ts +575 -0
- package/dist/bundled-extensions/plugin-skill-inductor/otto-plugin.json +8 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/plugin.cjs +52781 -0
- package/dist/bundled-extensions/plugin-skill-inductor/plugin.ts +77 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-detector.ts +210 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-digest.ts +146 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-store.ts +87 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode-store.ts +122 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode.ts +125 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/extraction-prompt.ts +97 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/inductor-service.ts +183 -0
- package/dist/bundled-extensions/plugin-skill-inductor/src/similarity.ts +58 -0
- package/dist/command-dispatcher-BgStVcCF.js +13 -0
- package/dist/command-dispatcher-BgStVcCF.js.map +1 -0
- package/dist/config-migration-DatflIxs.js +21 -0
- package/dist/config-migration-DatflIxs.js.map +1 -0
- package/dist/daemon-COESl-zU.js +21 -0
- package/dist/daemon-COESl-zU.js.map +1 -0
- package/dist/daemon-base-YOWgYYN7.js +3 -0
- package/dist/daemon-base-YOWgYYN7.js.map +1 -0
- package/dist/debug-CiosFwme.js +34 -0
- package/dist/debug-CiosFwme.js.map +1 -0
- package/dist/discover-with-bundled-CznLjkkw.js +3 -0
- package/dist/discover-with-bundled-CznLjkkw.js.map +1 -0
- package/dist/doctor-C84-hhEr.js +30 -0
- package/dist/doctor-C84-hhEr.js.map +1 -0
- package/dist/doctor-attach-_zeIJewx.js +42 -0
- package/dist/doctor-attach-_zeIJewx.js.map +1 -0
- package/dist/doctor-cdp-K_mIFRfA.js +5 -0
- package/dist/doctor-cdp-K_mIFRfA.js.map +1 -0
- package/dist/doctor-heap-Rr2nNvcw.js +11 -0
- package/dist/doctor-heap-Rr2nNvcw.js.map +1 -0
- package/dist/doctor-ps-OL3FGQYu.js +8 -0
- package/dist/doctor-ps-OL3FGQYu.js.map +1 -0
- package/dist/extension-Bhzoel6s.js +169 -0
- package/dist/extension-Bhzoel6s.js.map +1 -0
- package/dist/extension-plugin-CnF6O_zK.js +7 -0
- package/dist/extension-plugin-CnF6O_zK.js.map +1 -0
- package/dist/extension-plugin-DWcakwRx.js +2 -0
- package/dist/extension-v-liRnUX.js +2 -0
- package/dist/feedback-BES_6ebi.js +21 -0
- package/dist/feedback-BES_6ebi.js.map +1 -0
- package/dist/feedback-BRbLUn9u.js +2 -0
- package/dist/feishu-setup-D6vnuNLw.js +6 -0
- package/dist/feishu-setup-D6vnuNLw.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/install-DlVJRHRT.js +11 -0
- package/dist/install-DlVJRHRT.js.map +1 -0
- package/dist/install-WCcQ9k9M.js +2 -0
- package/dist/mcp-DbTYT7hB.js +12 -0
- package/dist/mcp-DbTYT7hB.js.map +1 -0
- package/dist/migrate-boDXhEyV.js +14 -0
- package/dist/migrate-boDXhEyV.js.map +1 -0
- package/dist/model-auth-C27iG6Jq.js +3 -0
- package/dist/model-auth-C27iG6Jq.js.map +1 -0
- package/dist/model-auth-C4bsWvvn.js +2 -0
- package/dist/observe-CVg-MRM2.js +32 -0
- package/dist/observe-CVg-MRM2.js.map +1 -0
- package/dist/parse-cli-tFDOeVU1.js +3 -0
- package/dist/parse-cli-tFDOeVU1.js.map +1 -0
- package/dist/persistenced-CCwHu1Ww.js +36 -0
- package/dist/persistenced-CCwHu1Ww.js.map +1 -0
- package/dist/proxy-BvdBsB1O.js +2 -0
- package/dist/proxy-evg7ZvoD.js +3 -0
- package/dist/proxy-evg7ZvoD.js.map +1 -0
- package/dist/ps-DKrluEuy.js +9 -0
- package/dist/ps-DKrluEuy.js.map +1 -0
- package/dist/registry-B3nfN62q.js +2 -0
- package/dist/registry-D0NONjLv.js +6 -0
- package/dist/registry-D0NONjLv.js.map +1 -0
- package/dist/remote-persistence-server-B_hONoVR.js +35 -0
- package/dist/remote-persistence-server-B_hONoVR.js.map +1 -0
- package/dist/run-attach-Ci5jYyCa.js +3 -0
- package/dist/run-attach-Ci5jYyCa.js.map +1 -0
- package/dist/serve-pVR6UIT9.js +21 -0
- package/dist/serve-pVR6UIT9.js.map +1 -0
- package/dist/service-client-BLNeugM9.js +3 -0
- package/dist/service-client-BLNeugM9.js.map +1 -0
- package/dist/time-travel-ZozuPPI-.js +21 -0
- package/dist/time-travel-ZozuPPI-.js.map +1 -0
- package/dist/version-C0qM-qJM.js +3 -0
- package/dist/version-C0qM-qJM.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* episode.ts —— RFC-318 M2:episode 数据模型与解析规整。
|
|
3
|
+
*
|
|
4
|
+
* **episode ≠ lesson**:lesson 是陈述性知识("这个坑要绕开",RFC-036 已有管线),episode 是
|
|
5
|
+
* **程序性**知识——"这类任务是按什么顺序做成的"。两者数据面物理隔离(RFC-318 D1/D3),
|
|
6
|
+
* episode 只被归纳器离线消费,默认不进 prompt。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 一条解决路径上的单步:工具名 + 一句话意图。 */
|
|
10
|
+
export interface EpisodeStep {
|
|
11
|
+
/** 工具名。来自 `readTranscript` 投影保留的 `toolName`(M1 grill 定案的下游消费点)。 */
|
|
12
|
+
tool: string
|
|
13
|
+
/** 这一步在做什么(供 M4 生成 SKILL.md 时还原可读流程)。 */
|
|
14
|
+
intent: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Episode {
|
|
18
|
+
/**
|
|
19
|
+
* 任务签名——归一化的任务描述,M3 相似检测的一级匹配键。
|
|
20
|
+
* 要求"去掉具体标识符后仍能表达任务类型"(如"给某个包新增一个受控能力"而非"给
|
|
21
|
+
* plugin 包加 readTranscript"),否则同类任务永远聚不成簇。
|
|
22
|
+
*/
|
|
23
|
+
taskSignature: string
|
|
24
|
+
/** 检索标签(小写)。 */
|
|
25
|
+
tags: string[]
|
|
26
|
+
/** 有序执行步骤——**这是 episode 的核心价值**,缺了它就退化成一条 lesson。 */
|
|
27
|
+
steps: EpisodeStep[]
|
|
28
|
+
/**
|
|
29
|
+
* 结果判定。只有 `success` 的轨迹值得归纳成 skill(Voyager 先例:技能入库前需验证成功);
|
|
30
|
+
* `partial`/`failure` 仍然存档——M3 可用它们做反面信号,且丢弃会让"这条路走不通"的
|
|
31
|
+
* 知识永久流失。
|
|
32
|
+
*/
|
|
33
|
+
outcome: 'success' | 'partial' | 'failure'
|
|
34
|
+
sessionId: string
|
|
35
|
+
/** 抽取时刻(epoch ms)。 */
|
|
36
|
+
at: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 字段上限(R6 治理内建:创建即带约束,不等膨胀后补救)。 */
|
|
40
|
+
export const MAX_SIGNATURE_CHARS = 200
|
|
41
|
+
export const MAX_INTENT_CHARS = 120
|
|
42
|
+
export const MAX_STEPS = 20
|
|
43
|
+
export const MAX_TAGS = 5
|
|
44
|
+
|
|
45
|
+
const OUTCOMES = new Set(['success', 'partial', 'failure'])
|
|
46
|
+
|
|
47
|
+
function clamp(text: string, max: number): string {
|
|
48
|
+
return text.length > max ? text.slice(0, max) : text
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeSteps(raw: unknown): EpisodeStep[] {
|
|
52
|
+
if (!Array.isArray(raw)) return []
|
|
53
|
+
const steps: EpisodeStep[] = []
|
|
54
|
+
for (const item of raw) {
|
|
55
|
+
if (steps.length >= MAX_STEPS) break
|
|
56
|
+
const s = item as { tool?: unknown; intent?: unknown }
|
|
57
|
+
const tool = typeof s?.tool === 'string' ? s.tool.trim() : ''
|
|
58
|
+
if (!tool) continue // 无工具名的步骤对路径骨架无价值,丢弃
|
|
59
|
+
const intent = typeof s?.intent === 'string' ? s.intent.trim() : ''
|
|
60
|
+
steps.push({ tool: clamp(tool, MAX_INTENT_CHARS), intent: clamp(intent, MAX_INTENT_CHARS) })
|
|
61
|
+
}
|
|
62
|
+
return steps
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeTags(raw: unknown): string[] {
|
|
66
|
+
if (!Array.isArray(raw)) return []
|
|
67
|
+
const tags: string[] = []
|
|
68
|
+
for (const item of raw) {
|
|
69
|
+
if (tags.length >= MAX_TAGS) break
|
|
70
|
+
if (typeof item !== 'string') continue
|
|
71
|
+
const tag = item.trim().toLowerCase()
|
|
72
|
+
if (tag && !tags.includes(tag)) tags.push(tag)
|
|
73
|
+
}
|
|
74
|
+
return tags
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 把模型输出解析成 `Episode`。
|
|
79
|
+
*
|
|
80
|
+
* **fail-closed(R4)**:非 JSON / 不是对象 / 显式 `null` / 缺关键字段 / 无有效步骤 →
|
|
81
|
+
* 一律返回 `null`,**绝不抛**。抽取是尽力而为的旁路观测,任何异常都不该冒泡成服务崩溃。
|
|
82
|
+
*
|
|
83
|
+
* 为什么"无有效步骤即丢弃":没有执行路径的 episode 退化成一条描述性文本,对 M3 的路径
|
|
84
|
+
* 相似检测零贡献,只会占治理配额、稀释真实信号。
|
|
85
|
+
*/
|
|
86
|
+
export function parseEpisode(raw: string, sessionId: string, now: number): Episode | null {
|
|
87
|
+
let data: unknown
|
|
88
|
+
try {
|
|
89
|
+
data = JSON.parse(extractJsonBlock(raw))
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
|
94
|
+
|
|
95
|
+
const obj = data as Record<string, unknown>
|
|
96
|
+
const taskSignature = typeof obj['taskSignature'] === 'string' ? obj['taskSignature'].trim() : ''
|
|
97
|
+
if (!taskSignature) return null
|
|
98
|
+
|
|
99
|
+
const steps = normalizeSteps(obj['steps'])
|
|
100
|
+
if (steps.length === 0) return null
|
|
101
|
+
|
|
102
|
+
const outcomeRaw = typeof obj['outcome'] === 'string' ? obj['outcome'] : ''
|
|
103
|
+
const outcome = OUTCOMES.has(outcomeRaw) ? (outcomeRaw as Episode['outcome']) : 'partial'
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
taskSignature: clamp(taskSignature, MAX_SIGNATURE_CHARS),
|
|
107
|
+
tags: normalizeTags(obj['tags']),
|
|
108
|
+
steps,
|
|
109
|
+
outcome,
|
|
110
|
+
sessionId,
|
|
111
|
+
at: now,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 从模型输出里取 JSON 主体——模型常把 JSON 包在 ```json 围栏里或前后加解释文字。
|
|
117
|
+
* 取第一个 `{` 到最后一个 `}`;不做更复杂的解析(失败自然落到 parse 的 fail-closed)。
|
|
118
|
+
*/
|
|
119
|
+
function extractJsonBlock(raw: string): string {
|
|
120
|
+
const trimmed = raw.trim()
|
|
121
|
+
const start = trimmed.indexOf('{')
|
|
122
|
+
const end = trimmed.lastIndexOf('}')
|
|
123
|
+
if (start === -1 || end === -1 || end <= start) return trimmed
|
|
124
|
+
return trimmed.slice(start, end + 1)
|
|
125
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extraction-prompt.ts —— RFC-318 M2:episode 抽取的策略面。
|
|
3
|
+
*
|
|
4
|
+
* **这个文件是"可被自迭代回路改进的策略"的落点**(RFC-318 §2 目标 5):归纳质量不好时,
|
|
5
|
+
* 回路可以对本文件提改进 diff,走与人类相同的审查流水线。故 prompt 独立成文件而非内联
|
|
6
|
+
* 进 service——让"改策略"是一次小而清晰的 diff,不是在流程代码里找字符串。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点(信噪比是本 RFC 最大风险,第一道闸在这里)——结构经 M2 审核后重排:
|
|
9
|
+
* 1. **放弃条件前置**:先列"什么时候输出 null"再给 JSON schema。原版把 schema 放前面,
|
|
10
|
+
* 会把模型强 prime 成"我要产出 JSON",后置的 null 分支敌不过这个先入印象——闲聊型
|
|
11
|
+
* 会话会被硬凑成 episode(审核指出的高危项)。
|
|
12
|
+
* 2. **判据可操作**:"worth remembering" 这类形容词在不同模型/温度下判定漂移很大,换成
|
|
13
|
+
* 可数的门槛(≥2 个工具步骤 + 有明确结局)。
|
|
14
|
+
* 3. **few-shot 优于指令**:给一正一反两个完整示例。让模型看见"该输出 null 的样子",
|
|
15
|
+
* 比多写三行禁令有效得多。
|
|
16
|
+
* 4. **taskSignature 去具体化说清楚"替换什么"**:列出要泛化的标识符类别(包名/函数名/
|
|
17
|
+
* 文件路径/变量名),而不是只给一对 good/bad——单个例子容易被模型学成"换个词"。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const EPISODE_EXTRACTION_PROMPT = `You are a trajectory analyst for an AI coding assistant. Read the session transcript below and extract the reusable SOLUTION PATH — the ordered tool steps — of the task that was carried out, so that similar future tasks can follow the same route.
|
|
21
|
+
|
|
22
|
+
## STEP 1 — Decide whether to extract anything at all
|
|
23
|
+
|
|
24
|
+
Output exactly \`null\` (nothing else, no JSON) if ANY of these is true:
|
|
25
|
+
|
|
26
|
+
1. No task was carried out — the session is chat, Q&A, explanation, or planning only.
|
|
27
|
+
2. Fewer than 2 real tool calls appear in the transcript.
|
|
28
|
+
3. The work is still in progress — no success, failure, or abandonment is visible yet.
|
|
29
|
+
4. The path is trivial: a single lookup answered directly (e.g. read one file → answer), with no decision-making, no iteration, no repair.
|
|
30
|
+
|
|
31
|
+
Be strict here. Most sessions deserve \`null\`. A wrong \`null\` costs nothing; a fabricated episode pollutes the skill library permanently.
|
|
32
|
+
|
|
33
|
+
## STEP 2 — Only if none of the above applies, output ONE JSON object
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"taskSignature": string, // The TASK TYPE, with every specific identifier generalized away.
|
|
37
|
+
// Replace package names, file paths, function names, variable names,
|
|
38
|
+
// and product names with their CATEGORY or ROLE.
|
|
39
|
+
// It must describe a class of tasks, not this one instance.
|
|
40
|
+
"tags": string[], // 1-5 lowercase keywords for retrieval
|
|
41
|
+
"steps": [ // The real tool sequence, in order
|
|
42
|
+
{ "tool": string, // Tool name EXACTLY as it appears in the transcript — never invent one
|
|
43
|
+
"intent": string } // One short phrase: what this step accomplished
|
|
44
|
+
],
|
|
45
|
+
"outcome": "success" | "partial" | "failure"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Rules for steps:
|
|
49
|
+
- Every step MUST correspond to a real tool call visible in the transcript. Do NOT invent, merge unrelated calls, or idealize the path into what "should" have been done.
|
|
50
|
+
- Collapse trivial repetition: five consecutive reads of different files become one "read" step describing the group.
|
|
51
|
+
- Drop abandoned detours that had no effect on the outcome.
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
|
|
55
|
+
### Example A — a real task (extract)
|
|
56
|
+
|
|
57
|
+
Transcript: user asks to add a rate limit to an API endpoint. Assistant greps for the middleware registry, reads the existing throttle helper, edits the route to apply it, runs the test suite, sees one failure, fixes the config, reruns — green.
|
|
58
|
+
|
|
59
|
+
Output:
|
|
60
|
+
{
|
|
61
|
+
"taskSignature": "apply an existing cross-cutting middleware to a specific API route and get the suite green",
|
|
62
|
+
"tags": ["api", "middleware", "config"],
|
|
63
|
+
"steps": [
|
|
64
|
+
{ "tool": "grep", "intent": "locate where middleware is registered" },
|
|
65
|
+
{ "tool": "read", "intent": "study the existing helper's interface" },
|
|
66
|
+
{ "tool": "edit", "intent": "apply the middleware to the target route" },
|
|
67
|
+
{ "tool": "bash", "intent": "run the test suite, one failure surfaced" },
|
|
68
|
+
{ "tool": "edit", "intent": "correct the configuration value" },
|
|
69
|
+
{ "tool": "bash", "intent": "rerun — all green" }
|
|
70
|
+
],
|
|
71
|
+
"outcome": "success"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Note how the signature says "an existing cross-cutting middleware" and "a specific API route" instead of naming the rate limiter or the route — that is what makes it match future tasks of the same shape.
|
|
75
|
+
|
|
76
|
+
### Example B — not a task (do not extract)
|
|
77
|
+
|
|
78
|
+
Transcript: user asks how the caching layer works. Assistant reads two files and explains the design. No changes made.
|
|
79
|
+
|
|
80
|
+
Output:
|
|
81
|
+
null
|
|
82
|
+
|
|
83
|
+
## Output format
|
|
84
|
+
|
|
85
|
+
Output ONLY the JSON object, or the bare word \`null\`. No prose, no code fences, no explanation.`
|
|
86
|
+
|
|
87
|
+
/** 把会话切片渲染成 transcript 文本。 */
|
|
88
|
+
export function buildEpisodePrompt(
|
|
89
|
+
messages: readonly { role: string; text: string; toolName?: string }[],
|
|
90
|
+
): string {
|
|
91
|
+
const lines = messages.map((m) => {
|
|
92
|
+
// 工具名单独标出——它是 steps[].tool 的唯一可信来源(模型不该从散文里猜工具名)。
|
|
93
|
+
const tag = m.toolName ? `${m.role}(${m.toolName})` : m.role
|
|
94
|
+
return `<${tag}>\n${m.text}\n</${tag}>`
|
|
95
|
+
})
|
|
96
|
+
return `${EPISODE_EXTRACTION_PROMPT}\n\n<transcript>\n${lines.join('\n')}\n</transcript>`
|
|
97
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inductor-service.ts —— RFC-318 M2:归纳器 service 的主流程。
|
|
3
|
+
*
|
|
4
|
+
* 生命周期:宿主在会话空闲时经 `pool.notify` 推 `session.idle` 事件(RFC-318 D9),
|
|
5
|
+
* 本 service 据此读增量轨迹 → 抽 episode → 落 scoped storage。
|
|
6
|
+
*
|
|
7
|
+
* **fail-closed 且不阻塞(R4)**:全流程任何一步失败都只 warn 并返回。notify handler 抛出
|
|
8
|
+
* 异常会被 worker 上报为服务崩溃并触发退避重启——对"这次没抽出东西"这种常态而言,那是
|
|
9
|
+
* 完全错误的严重级别。
|
|
10
|
+
*
|
|
11
|
+
* **配额意识(R2)**:宿主侧对 `readTranscript`(每 notify 1 次)与 `complete`(每 notify
|
|
12
|
+
* 2 次)有硬配额。本 service 每次 notify 最多各调 1 次,天然在预算内——但绝不自造重试
|
|
13
|
+
* 循环去试探配额边界(那是把宿主强制当成可协商的)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parseEpisode } from './episode'
|
|
17
|
+
import { buildEpisodePrompt } from './extraction-prompt'
|
|
18
|
+
import { advanceCursor, appendEpisode, loadRecord, saveRecord } from './episode-store'
|
|
19
|
+
import { detectCandidates } from './candidate-detector'
|
|
20
|
+
import { loadCandidates, mergeCandidates, saveCandidates } from './candidate-store'
|
|
21
|
+
|
|
22
|
+
import type { EpisodeStorage } from './episode-store'
|
|
23
|
+
import type { CandidateStorage } from './candidate-store'
|
|
24
|
+
import type { EpisodeRecord } from './episode-store'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 少于这么多条新消息就不抽取——只推进游标。
|
|
28
|
+
* 对齐 RFC-036 `createMemoryAutoExtractHooks` 的 `minMessages: 6` 口径:太短的切片
|
|
29
|
+
* 抽不出有意义的路径,白烧一次模型调用。
|
|
30
|
+
*
|
|
31
|
+
* **这是缺省值**——宿主可经通知的 `options.minMessages` 覆盖(用户在 /preference 里调)。
|
|
32
|
+
* 插件读不到宿主 settings(隔离 worker,刻意不给这个能力),故走下发。
|
|
33
|
+
*/
|
|
34
|
+
export const MIN_MESSAGES_FOR_EXTRACTION = 6
|
|
35
|
+
|
|
36
|
+
/** 阈值下限——低于 2 条消息无论如何抽不出路径,防止用户把它调成 0 后每次 idle 都烧模型。 */
|
|
37
|
+
export const MIN_MESSAGES_FLOOR = 2
|
|
38
|
+
|
|
39
|
+
/** 解析生效阈值:宿主下发优先,非法值(非数字/过小)回退缺省。 */
|
|
40
|
+
export function resolveMinMessages(fromHost?: number): number {
|
|
41
|
+
if (typeof fromHost !== 'number' || !Number.isFinite(fromHost)) {
|
|
42
|
+
return MIN_MESSAGES_FOR_EXTRACTION
|
|
43
|
+
}
|
|
44
|
+
return Math.max(MIN_MESSAGES_FLOOR, Math.floor(fromHost))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 抽取调用的 token 上限——episode 是结构化短输出,比 lesson 抽取(2048)更省。 */
|
|
48
|
+
export const EXTRACTION_MAX_TOKENS = 1_024
|
|
49
|
+
|
|
50
|
+
/** service 运行所需的宿主能力(全部经 M1 的受控能力面注入,缺一即不工作)。 */
|
|
51
|
+
export interface InductorHost {
|
|
52
|
+
readTranscript?: (input: { sessionId: string; sinceIndex?: number }) => Promise<{
|
|
53
|
+
messages: readonly { role: string; text: string; toolName?: string }[]
|
|
54
|
+
nextIndex: number
|
|
55
|
+
}>
|
|
56
|
+
complete?: (input: { prompt: string; maxTokens?: number }) => Promise<string>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface InductorDeps {
|
|
60
|
+
host?: InductorHost
|
|
61
|
+
/** episode 与 candidate 共用同一份 scoped storage(各占一条记录)。 */
|
|
62
|
+
storage?: EpisodeStorage & CandidateStorage
|
|
63
|
+
logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
64
|
+
now?: () => number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type InductionOutcome =
|
|
68
|
+
| 'missing-capability'
|
|
69
|
+
| 'in-flight'
|
|
70
|
+
| 'no-new-messages'
|
|
71
|
+
| 'too-short'
|
|
72
|
+
| 'no-episode'
|
|
73
|
+
| 'saved'
|
|
74
|
+
| 'error'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 归纳器核心——与 service 生命周期解耦,便于单测直接驱动(注入假 host/storage)。
|
|
78
|
+
*/
|
|
79
|
+
export class SkillInductor {
|
|
80
|
+
/** 正在处理中的会话——同 session 的并发 notify 直接丢弃(下次 notify 会补上)。 */
|
|
81
|
+
private readonly inFlight = new Set<string>()
|
|
82
|
+
|
|
83
|
+
constructor(private readonly deps: InductorDeps) {}
|
|
84
|
+
|
|
85
|
+
private get now(): number {
|
|
86
|
+
return this.deps.now ? this.deps.now() : Date.now()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async onSessionIdle(sessionId: string, options?: { minMessages?: number }): Promise<InductionOutcome> {
|
|
90
|
+
const { host, storage, logger } = this.deps
|
|
91
|
+
// 能力自检:未授予时宿主注入的是 undefined(fail-closed 跨越 RPC 边界),不是会抛错的
|
|
92
|
+
// 假句柄——所以这里判存在性即可,不需要 try/catch 试探。
|
|
93
|
+
if (!host?.readTranscript || !host.complete || !storage) {
|
|
94
|
+
logger.warn(
|
|
95
|
+
'skill-inductor idle: required capabilities missing (needs session.read + llm.complete + storage), skipping',
|
|
96
|
+
)
|
|
97
|
+
return 'missing-capability'
|
|
98
|
+
}
|
|
99
|
+
if (this.inFlight.has(sessionId)) return 'in-flight'
|
|
100
|
+
this.inFlight.add(sessionId)
|
|
101
|
+
try {
|
|
102
|
+
return await this.run(
|
|
103
|
+
sessionId,
|
|
104
|
+
host.readTranscript,
|
|
105
|
+
host.complete,
|
|
106
|
+
storage,
|
|
107
|
+
resolveMinMessages(options?.minMessages),
|
|
108
|
+
)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// 兜底:任何未预期异常都收在这里,绝不冒泡到 notify handler(R4)。
|
|
111
|
+
logger.warn(`skill-inductor idle failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
112
|
+
return 'error'
|
|
113
|
+
} finally {
|
|
114
|
+
this.inFlight.delete(sessionId)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async run(
|
|
119
|
+
sessionId: string,
|
|
120
|
+
readTranscript: NonNullable<InductorHost['readTranscript']>,
|
|
121
|
+
complete: NonNullable<InductorHost['complete']>,
|
|
122
|
+
storage: EpisodeStorage & CandidateStorage,
|
|
123
|
+
minMessages: number,
|
|
124
|
+
): Promise<InductionOutcome> {
|
|
125
|
+
const record = await loadRecord(storage)
|
|
126
|
+
const sinceIndex = record.cursor[sessionId] ?? 0
|
|
127
|
+
|
|
128
|
+
const { messages, nextIndex } = await readTranscript({ sessionId, sinceIndex })
|
|
129
|
+
if (messages.length === 0) {
|
|
130
|
+
// 游标仍要推进:nextIndex 可能因宿主侧的条数上限跳过了旧消息。
|
|
131
|
+
if (nextIndex !== sinceIndex) await saveRecord(storage, advanceCursor(record, sessionId, nextIndex))
|
|
132
|
+
return 'no-new-messages'
|
|
133
|
+
}
|
|
134
|
+
if (messages.length < minMessages) {
|
|
135
|
+
// **不推进游标**:切片太短是"还没攒够",下次 notify 应该带上这些消息一起抽。
|
|
136
|
+
return 'too-short'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const raw = await complete({
|
|
140
|
+
prompt: buildEpisodePrompt(messages),
|
|
141
|
+
maxTokens: EXTRACTION_MAX_TOKENS,
|
|
142
|
+
})
|
|
143
|
+
const episode = parseEpisode(raw, sessionId, this.now)
|
|
144
|
+
|
|
145
|
+
// 抽不出 episode 也要推进游标——这一段已经判过"没有可归纳的路径",重复抽只会
|
|
146
|
+
// 重复烧模型调用(信噪比与成本的直接权衡点)。
|
|
147
|
+
const advanced = advanceCursor(record, sessionId, nextIndex)
|
|
148
|
+
if (!episode) {
|
|
149
|
+
await saveRecord(storage, advanced)
|
|
150
|
+
return 'no-episode'
|
|
151
|
+
}
|
|
152
|
+
const withEpisode = appendEpisode(advanced, episode)
|
|
153
|
+
await saveRecord(storage, withEpisode)
|
|
154
|
+
this.deps.logger.info(`skill-inductor captured episode: ${episode.taskSignature}`)
|
|
155
|
+
// 只在真的新增了 episode 后才跑检测——没有新数据就不可能出新簇。
|
|
156
|
+
await this.refreshCandidates(storage, withEpisode)
|
|
157
|
+
return 'saved'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 重算候选并落盘(RFC-318 D-M3-5)。
|
|
162
|
+
*
|
|
163
|
+
* **失败静默**:这是 episode 抽取的下游附加动作,episode 已经安全落盘;检测出问题不该
|
|
164
|
+
* 反过来把主链路标记成失败(R4)。
|
|
165
|
+
*/
|
|
166
|
+
private async refreshCandidates(
|
|
167
|
+
storage: EpisodeStorage & CandidateStorage,
|
|
168
|
+
record: EpisodeRecord,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
try {
|
|
171
|
+
const detected = detectCandidates(record.items)
|
|
172
|
+
const existing = await loadCandidates(storage)
|
|
173
|
+
// 合并而非覆盖——既有候选的 dismissedAt 必须活下来(R9),否则每次抽取都会把
|
|
174
|
+
// 用户的拒绝抹掉,冷却形同虚设。
|
|
175
|
+
const items = mergeCandidates(existing.items, detected)
|
|
176
|
+
await saveCandidates(storage, { items })
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.deps.logger.warn(
|
|
179
|
+
`skill-inductor candidate detection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* similarity.ts —— RFC-318 M3:episode 相似度(确定性,无 embedding)。
|
|
3
|
+
*
|
|
4
|
+
* **为什么不复用 core 的实现**:`@x-otto/memory` 里有同款 `jaccard`/`toWordSet`
|
|
5
|
+
* (operational-learning.ts:86,95),但它们未导出、且属 core 包——插件跑在独立编译产物 +
|
|
6
|
+
* 隔离 worker 里,不能依赖 core。故独立实现,**口径刻意对齐既有版本**(小写化、按非
|
|
7
|
+
* 字母数字切分、丢弃长度 ≤2 的词),避免同一概念在两处产生语义漂移。
|
|
8
|
+
*
|
|
9
|
+
* **为什么首版不用 embedding**(RFC-318 D4):零新依赖、结果确定可复现(同样的 episode
|
|
10
|
+
* 集合永远聚出同样的簇——这是簇 id 稳定性的前提,见 candidate-detector),且 RFC-284 的
|
|
11
|
+
* 探针数据证明 Jaccard 在本仓这个数据规模上够用。召回不足再单独立项。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Episode } from './episode'
|
|
15
|
+
|
|
16
|
+
/** taskSignature 相似度在总分里的权重(主信号)。 */
|
|
17
|
+
export const SIGNATURE_WEIGHT = 0.8
|
|
18
|
+
/** tags 交集在总分里的权重(修正信号)。 */
|
|
19
|
+
export const TAG_WEIGHT = 0.2
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 文本 → 词集。丢弃长度 ≤2 的词:冠词/介词/短代码片段对主题判别几乎无贡献,
|
|
23
|
+
* 却会因为高频出现而抬高所有配对的分数(噪声地板)。
|
|
24
|
+
*/
|
|
25
|
+
export function toWordSet(text: string): Set<string> {
|
|
26
|
+
return new Set(
|
|
27
|
+
text
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
30
|
+
.filter((w) => w.length > 2),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Jaccard 词集相似度 [0,1]。任一为空即 0(空集与任何集合"相似"是无意义的判定)。 */
|
|
35
|
+
export function jaccard(a: Set<string>, b: Set<string>): number {
|
|
36
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
37
|
+
let intersection = 0
|
|
38
|
+
for (const w of a) {
|
|
39
|
+
if (b.has(w)) intersection++
|
|
40
|
+
}
|
|
41
|
+
return intersection / (a.size + b.size - intersection)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 两条 episode 的相似度 [0,1]——两级加权(RFC-318 D4)。
|
|
46
|
+
*
|
|
47
|
+
* 一级 `taskSignature` 词集 Jaccard 是主信号;二级 `tags` 交集只占 0.2:tags 由模型自由
|
|
48
|
+
* 生成、稳定性差(同类任务可能标 `plugin` 也可能标 `capability`),当主信号会引入噪音;
|
|
49
|
+
* 但它能救回"措辞完全不同、主题相同"的漏配,故保留小权重。
|
|
50
|
+
*
|
|
51
|
+
* **刻意不比对 steps 序列**:同一类任务的执行路径本就允许分支(那正是要归纳的东西),
|
|
52
|
+
* 拿它当聚簇条件会把同类任务拆散。steps 是簇成立后归纳 SKILL.md 的素材,不是聚簇依据。
|
|
53
|
+
*/
|
|
54
|
+
export function episodeSimilarity(a: Episode, b: Episode): number {
|
|
55
|
+
const signature = jaccard(toWordSet(a.taskSignature), toWordSet(b.taskSignature))
|
|
56
|
+
const tags = jaccard(new Set(a.tags), new Set(b.tags))
|
|
57
|
+
return SIGNATURE_WEIGHT * signature + TAG_WEIGHT * tags
|
|
58
|
+
}
|