@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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin.ts —— plugin-skill-inductor 插件入口(RFC-318 M2)。
|
|
3
|
+
*
|
|
4
|
+
* otto 的第一个 `services` 型内部插件:常驻 worker,靠宿主推来的 `session.idle` 通知
|
|
5
|
+
* 干活(RFC-318 D9),而不是自持定时器轮询——轮询既拿不到 sessionId、也会在空闲期
|
|
6
|
+
* 无谓烧 CPU。
|
|
7
|
+
*
|
|
8
|
+
* **为什么归纳器是插件而不是 core 纯库**(RFC-318 D1,用户 grill 定调):归纳策略本身
|
|
9
|
+
* (抽取 prompt、阈值)要能被自迭代回路自己升级——插件形态使"改进归纳器"与"改进任何
|
|
10
|
+
* 其他能力"走同一条提案→diff→审查→热加载流水线。core 纯库做不到热替换。
|
|
11
|
+
*
|
|
12
|
+
* **高危能力零豁免(R1)**:service / session.read / llm.complete 都要用户逐项 grant,本插件
|
|
13
|
+
* 不因"内置"而绕过信任门。user.notify/storage 仍按各自既有风险分层处理。
|
|
14
|
+
*/
|
|
15
|
+
import { definePlugin } from '@x-otto/plugin'
|
|
16
|
+
|
|
17
|
+
import { SkillInductor } from './src/inductor-service'
|
|
18
|
+
import { runCandidateDigest } from './src/candidate-digest'
|
|
19
|
+
|
|
20
|
+
import type { PluginServiceContext, PluginServiceNotification } from '@x-otto/plugin'
|
|
21
|
+
|
|
22
|
+
export default definePlugin(() => ({
|
|
23
|
+
services: [
|
|
24
|
+
{
|
|
25
|
+
id: 'inductor',
|
|
26
|
+
/**
|
|
27
|
+
* RFC-327 M4:候选摘要的定时报告。
|
|
28
|
+
*
|
|
29
|
+
* 这是 otto 第一条真实的"自迭代定时回路"——宿主 core schedule 到点 →
|
|
30
|
+
* `schedule.tick` 投递到本隔离 worker → 只读自己的候选存储 → 有值得处理的事时
|
|
31
|
+
* 经 `host.notify` 提示用户查看候选。常驻 service 不持有可验证的会话交互目标,故不得
|
|
32
|
+
* 调用 `host.ask`(RFC-350 fail-closed);cron 解析/持久化/崩溃恢复全归 core(R4),
|
|
33
|
+
* 本插件拿不到 timer handle。
|
|
34
|
+
*
|
|
35
|
+
* 每 4 小时一次:低频足以避免噪音,又能在用户结束一段工作后的合理窗口内提醒。
|
|
36
|
+
* 没有新候选时**完全静默**(见 candidate-digest 的"静默是常态")。
|
|
37
|
+
*/
|
|
38
|
+
schedules: [{ key: 'candidate-digest', cron: '0 */4 * * *', name: '技能候选摘要' }],
|
|
39
|
+
start(ctx: PluginServiceContext) {
|
|
40
|
+
const inductor = new SkillInductor({
|
|
41
|
+
host: ctx.host,
|
|
42
|
+
storage: ctx.storage,
|
|
43
|
+
logger: ctx.logger,
|
|
44
|
+
})
|
|
45
|
+
// onNotify 是 M1 新增的被动接收面(无需能力声明——真正的数据面是 readTranscript)。
|
|
46
|
+
// 宿主未提供时(旧版本 otto)静默降级为"不工作",而不是崩溃。
|
|
47
|
+
ctx.onNotify?.((n: PluginServiceNotification) => {
|
|
48
|
+
// RFC-327 M4:定时到点 → 候选摘要(只读 + 可能通知,不产生工作)。
|
|
49
|
+
if (n.type === 'schedule.tick') {
|
|
50
|
+
if (!ctx.storage) return
|
|
51
|
+
return runCandidateDigest(
|
|
52
|
+
{ storage: ctx.storage, notify: ctx.host?.notify, logger: ctx.logger },
|
|
53
|
+
{ scheduleId: n.scheduleId, occurrenceId: n.occurrenceId },
|
|
54
|
+
)
|
|
55
|
+
.then(() => undefined)
|
|
56
|
+
.catch(() => undefined) // 同下:报告失败不该被记成服务崩溃
|
|
57
|
+
}
|
|
58
|
+
if (n.type !== 'session.idle') return
|
|
59
|
+
// handler 内部已全程 fail-closed;返回 Promise 让 worker 侧能 await 完成,
|
|
60
|
+
// 避免服务停机时截断进行中的抽取。
|
|
61
|
+
// `.catch` 是防御纵深:`onSessionIdle` 的同步前段(能力自检里的 logger 调用)
|
|
62
|
+
// 若抛错会绕过其内部 try/catch,冒泡成 unhandled rejection——那会被 worker 记成
|
|
63
|
+
// 服务崩溃并触发退避重启,对"这次没抽出东西"是完全错误的严重级别。
|
|
64
|
+
return inductor
|
|
65
|
+
.onSessionIdle(n.sessionId, n.options)
|
|
66
|
+
.then(() => undefined)
|
|
67
|
+
.catch(() => undefined)
|
|
68
|
+
})
|
|
69
|
+
ctx.logger.info('skill-inductor service started')
|
|
70
|
+
},
|
|
71
|
+
stop() {
|
|
72
|
+
// 无外部连接/定时器需要收敛——in-flight 的抽取由 worker 停机窗口自然完成或被截断,
|
|
73
|
+
// 截断的代价只是"下次 notify 重抽一段",无状态损坏(游标只在成功保存后推进)。
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
}))
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* candidate-detector.ts —— RFC-318 M3:把 episode 聚成"同类任务重复出现"的候选。
|
|
3
|
+
*
|
|
4
|
+
* 这是技能回路的判别环节:**什么时候值得打断用户,问"要不要把这套流程固化成 skill"**。
|
|
5
|
+
* 门槛太低会变成骚扰(RFC-318 §8 列为最大风险之一),太高则回路永远不触发。首版取
|
|
6
|
+
* "最近 50 条里同类成功轨迹出现 ≥3 次"。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
import { episodeSimilarity } from './similarity'
|
|
12
|
+
|
|
13
|
+
import type { Episode } from './episode'
|
|
14
|
+
|
|
15
|
+
/** 只看最近这么多条 episode——更早的路径可能已因代码演进失效。 */
|
|
16
|
+
export const DETECT_WINDOW = 50
|
|
17
|
+
/**
|
|
18
|
+
* 并簇阈值(`episodeSimilarity` 的加权分)。
|
|
19
|
+
*
|
|
20
|
+
* **0.25 是量出来的,不是拍的**。RFC 草案里写的 0.5 在施工时被实测推翻:拿三组真实纹理
|
|
21
|
+
* 的同义改写签名(插件能力/flaky 测试/provider 注册各 3 条)算全部配对分数——
|
|
22
|
+
*
|
|
23
|
+
* 同类对:min 0.285 / avg 0.496 / max 0.755
|
|
24
|
+
* 跨类对:max 0.160(avg 0.041)
|
|
25
|
+
*
|
|
26
|
+
* 分离度很好,但 0.5 正好切在**同类分布的中间**,近一半真实同类对会被判为不相似而漏配
|
|
27
|
+
* (连通分量能救回一部分,但 min 0.285 那种远低于 0.5 的仍会丢)。取 0.25:低于同类下界
|
|
28
|
+
* 0.285、高于跨类上界 0.160,中间留 0.125 的安全带。
|
|
29
|
+
*
|
|
30
|
+
* 词集 Jaccard 的分数天然偏低——同义改写会换掉一半实词("fix…that fails under
|
|
31
|
+
* concurrency" vs "repair…under concurrent execution"),交并比自然掉到 0.3 附近。用
|
|
32
|
+
* 直觉里的"相似度该有 0.7"来定阈值是错的。
|
|
33
|
+
*/
|
|
34
|
+
export const SIMILARITY_THRESHOLD = 0.25
|
|
35
|
+
/** 成簇门槛——少于这么多条同类不算"重复出现",不产候选。 */
|
|
36
|
+
export const MIN_CLUSTER_SIZE = 3
|
|
37
|
+
|
|
38
|
+
/** 候选(M4a 宿主薄读取器的消费对象;字段即 grill 简报可展示的元信息面)。 */
|
|
39
|
+
export interface SkillCandidate {
|
|
40
|
+
/** 稳定簇 id(见 `clusterIdOf` —— 关系到 R9 冷却能否生效)。 */
|
|
41
|
+
id: string
|
|
42
|
+
/** 代表性任务签名:取簇内最长者(信息量最大)。 */
|
|
43
|
+
taskSignature: string
|
|
44
|
+
tags: string[]
|
|
45
|
+
/** 簇成员会话 id(用户追溯入口)。 */
|
|
46
|
+
sessionIds: string[]
|
|
47
|
+
size: number
|
|
48
|
+
firstAt: number
|
|
49
|
+
lastAt: number
|
|
50
|
+
/** 用户拒绝时刻(R9 冷却)。检测器不产生此字段,由 store 合并时保留。 */
|
|
51
|
+
dismissedAt?: number
|
|
52
|
+
/** RFC-349 Should-2:候选被采纳为 skill 的时刻(用户在 grill 里选"记录"后由宿主回填)。
|
|
53
|
+
* 与 `dismissedAt` 对称——后者是"用户不要",前者是"用户要了"。 */
|
|
54
|
+
appliedAt?: number
|
|
55
|
+
/** RFC-349 Should-2:该候选产出的 skill 被注入到 prompt 的次数(宿主侧追踪)。
|
|
56
|
+
* 0 = 已生成但从未被使用(可能噪声 skill);高值 = 高价值 skill。 */
|
|
57
|
+
usageCount?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DetectOptions {
|
|
61
|
+
window?: number
|
|
62
|
+
threshold?: number
|
|
63
|
+
minClusterSize?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 簇 id —— **必须在簇成长时保持不变**,否则已被用户拒绝的候选会因为多了一个成员而
|
|
68
|
+
* 换个 id"复活",直接绕过 R9 的 14 天冷却,变成反复骚扰。
|
|
69
|
+
*
|
|
70
|
+
* 故 id 只由**最早 `MIN_CLUSTER_SIZE` 个成员**的 sessionId 派生:簇一旦成立,这三个成员
|
|
71
|
+
* 就不再变化(后来的只会追加在后面),id 就此固定。
|
|
72
|
+
* 用内容哈希或全量成员哈希都会随成长而变——那正是要避免的。
|
|
73
|
+
*
|
|
74
|
+
* **已知残余风险(审核记录,灰度期观察)**:episode 集合有 200 条 FIFO 上限,若某个任务
|
|
75
|
+
* 类型极高频、把最早的簇成员挤出集合,种子随之变化 → id 变 → 该簇的 dismissedAt 查不到,
|
|
76
|
+
* 冷却丢失、会被重新问一次。触发条件苛刻(需该类型持续活跃到撑满整个集合),且后果是
|
|
77
|
+
* "多问一次"而非数据损坏,故首版接受。若灰度观测到,改用"签名 + 最早成员集"的复合键。
|
|
78
|
+
*/
|
|
79
|
+
export function clusterIdOf(members: readonly Episode[], minClusterSize: number): string {
|
|
80
|
+
const seed = [...members]
|
|
81
|
+
.sort((a, b) => a.at - b.at)
|
|
82
|
+
.slice(0, minClusterSize)
|
|
83
|
+
.map((e) => e.sessionId)
|
|
84
|
+
.join('|')
|
|
85
|
+
return `cand_${createHash('sha256').update(seed).digest('hex').slice(0, 12)}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 检测候选。纯函数:同样的输入永远得到同样的输出(簇 id 稳定性的前提)。
|
|
90
|
+
*
|
|
91
|
+
* **只聚 `outcome === 'success'`**:失败/半成功的轨迹不该被归纳成"最佳路径"(Voyager
|
|
92
|
+
* 先例:技能入库前需验证成功)。它们仍留在 episode store——"这条路走不通"也是知识,
|
|
93
|
+
* 只是不进候选。
|
|
94
|
+
*
|
|
95
|
+
* **聚簇 = 相似图的连通分量**(union-find),不是贪心单遍。
|
|
96
|
+
*
|
|
97
|
+
* 贪心(按时间倒序、与已有簇比较后并入)的问题是**结果依赖遍历顺序**:当三条同类 episode
|
|
98
|
+
* 中存在一条"两两相似度参差"的(A~B、A~C 达阈值但 B~C 不达),先遇到谁决定了它们是聚成
|
|
99
|
+
* 一簇还是碎成两簇——同一批数据换个到达顺序就得到不同的簇,而簇 id 由成员派生,簇不稳定
|
|
100
|
+
* 意味着 id 不稳定,R9 冷却直接失效。连通分量没有这个问题:任意两条达阈值即连边,簇是边的
|
|
101
|
+
* 传递闭包,与遍历顺序无关。
|
|
102
|
+
*
|
|
103
|
+
* (施工记录:这个缺陷是在阈值还是草案值 0.5 时被一组真实签名暴露的——A~B=0.66、A~C=0.61、
|
|
104
|
+
* B~C=0.43,倒序遍历把簇切成 2+1、候选凭空消失。阈值后来标定到 0.25,那组具体数字已不再
|
|
105
|
+
* 触发问题,但顺序敏感性是算法固有的,与阈值取值无关,故变更保留。)
|
|
106
|
+
*
|
|
107
|
+
* 代价是链式漂移(A~B、B~C 达阈值但 A 与 C 不像时三者仍同簇)。对"同类任务"这个语义可接受:
|
|
108
|
+
* 签名已是去具体化的任务类型描述,链式相似基本意味着确实同类;而顺序敏感会直接丢候选,
|
|
109
|
+
* 危害大得多。灰度期若观测到误聚率偏高,再考虑加簇内 pair 最小相似度校验。
|
|
110
|
+
*/
|
|
111
|
+
export function detectCandidates(
|
|
112
|
+
episodes: readonly Episode[],
|
|
113
|
+
options: DetectOptions = {},
|
|
114
|
+
): SkillCandidate[] {
|
|
115
|
+
const window = options.window ?? DETECT_WINDOW
|
|
116
|
+
const threshold = options.threshold ?? SIMILARITY_THRESHOLD
|
|
117
|
+
const minClusterSize = options.minClusterSize ?? MIN_CLUSTER_SIZE
|
|
118
|
+
|
|
119
|
+
// 先按时间取最近 window 条(窗口语义按时间,不按输入顺序)。
|
|
120
|
+
// `isUsable` 逐条剔除脏数据而不是让整批检测失败:store 里的 items 是 `as Episode[]`
|
|
121
|
+
// 断言出来的(loadRecord 不做运行时逐字段校验),一条坏记录若能掀翻整轮检测,就会
|
|
122
|
+
// 让**所有**候选停止更新——fail-closed 没错,但颗粒度必须是单条而非整批。
|
|
123
|
+
const recent = [...episodes]
|
|
124
|
+
.filter((e) => isUsable(e) && e.outcome === 'success')
|
|
125
|
+
.sort((a, b) => a.at - b.at)
|
|
126
|
+
.slice(-window)
|
|
127
|
+
|
|
128
|
+
return groupByConnectivity(recent, threshold)
|
|
129
|
+
.filter((c) => c.length >= minClusterSize)
|
|
130
|
+
.map((members) => toCandidate(members, minClusterSize))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 单条 episode 是否可参与聚簇。
|
|
135
|
+
*
|
|
136
|
+
* 防的是持久化里的脏数据(手改文件、旧版本格式、写入截断):`at` 为 NaN 会让排序行为
|
|
137
|
+
* 不确定并把 `firstAt`/`lastAt` 污染成 NaN 传给 M4a 展示;`tags` 非数组会让 `new Set()`
|
|
138
|
+
* 得到意外结果;空签名则根本无法参与相似度判定(词集为空,恒为 0 分)。
|
|
139
|
+
*/
|
|
140
|
+
function isUsable(e: Episode): boolean {
|
|
141
|
+
return (
|
|
142
|
+
typeof e?.at === 'number' &&
|
|
143
|
+
Number.isFinite(e.at) &&
|
|
144
|
+
typeof e.taskSignature === 'string' &&
|
|
145
|
+
e.taskSignature.trim().length > 0 &&
|
|
146
|
+
typeof e.sessionId === 'string' &&
|
|
147
|
+
Array.isArray(e.tags)
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 相似图的连通分量(union-find)。O(n²) 比较——n ≤ window(默认 50),可忽略。 */
|
|
152
|
+
function groupByConnectivity(episodes: readonly Episode[], threshold: number): Episode[][] {
|
|
153
|
+
const parent = episodes.map((_, i) => i)
|
|
154
|
+
const find = (i: number): number => {
|
|
155
|
+
let root = i
|
|
156
|
+
while (parent[root] !== root) root = parent[root] as number
|
|
157
|
+
// 路径压缩
|
|
158
|
+
let cur = i
|
|
159
|
+
while (parent[cur] !== root) {
|
|
160
|
+
const next = parent[cur] as number
|
|
161
|
+
parent[cur] = root
|
|
162
|
+
cur = next
|
|
163
|
+
}
|
|
164
|
+
return root
|
|
165
|
+
}
|
|
166
|
+
const union = (a: number, b: number): void => {
|
|
167
|
+
const ra = find(a)
|
|
168
|
+
const rb = find(b)
|
|
169
|
+
if (ra !== rb) parent[Math.max(ra, rb)] = Math.min(ra, rb)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < episodes.length; i++) {
|
|
173
|
+
for (let j = i + 1; j < episodes.length; j++) {
|
|
174
|
+
const a = episodes[i] as Episode
|
|
175
|
+
const b = episodes[j] as Episode
|
|
176
|
+
if (episodeSimilarity(a, b) >= threshold) union(i, j)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const groups = new Map<number, Episode[]>()
|
|
181
|
+
for (let i = 0; i < episodes.length; i++) {
|
|
182
|
+
const root = find(i)
|
|
183
|
+
const bucket = groups.get(root)
|
|
184
|
+
if (bucket) bucket.push(episodes[i] as Episode)
|
|
185
|
+
else groups.set(root, [episodes[i] as Episode])
|
|
186
|
+
}
|
|
187
|
+
return [...groups.values()]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toCandidate(members: readonly Episode[], minClusterSize: number): SkillCandidate {
|
|
191
|
+
const byTime = [...members].sort((a, b) => a.at - b.at)
|
|
192
|
+
// 代表签名取最长者:簇内各条措辞不同,最长的通常保留了最多的任务特征。
|
|
193
|
+
const representative = [...members].sort(
|
|
194
|
+
(a, b) => b.taskSignature.length - a.taskSignature.length,
|
|
195
|
+
)[0] as Episode
|
|
196
|
+
// 标签取并集(去重,保持首次出现顺序)——单条 episode 的 tags 可能不全。
|
|
197
|
+
const tags: string[] = []
|
|
198
|
+
for (const m of byTime) {
|
|
199
|
+
for (const t of m.tags) if (!tags.includes(t)) tags.push(t)
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
id: clusterIdOf(members, minClusterSize),
|
|
203
|
+
taskSignature: representative.taskSignature,
|
|
204
|
+
tags,
|
|
205
|
+
sessionIds: [...new Set(byTime.map((m) => m.sessionId))],
|
|
206
|
+
size: members.length,
|
|
207
|
+
firstAt: (byTime[0] as Episode).at,
|
|
208
|
+
lastAt: (byTime[byTime.length - 1] as Episode).at,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { loadCandidates, type CandidateStorage } from './candidate-store'
|
|
2
|
+
|
|
3
|
+
import type { SkillCandidate } from './candidate-detector'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RFC-327 M4:候选摘要报告器——第一个把「timer → 只读计算 → 用户可见状态」串起来的真实消费者。
|
|
7
|
+
*
|
|
8
|
+
* 它存在的意义不是"再加一个功能",而是**证明 D1-D4/RFC-344 的能力面真的能串成回路**:
|
|
9
|
+
* host schedule 到点 → `schedule.tick` 投递到隔离 worker → service 读自己的 scoped
|
|
10
|
+
* storage 做纯计算 → 经 `host.notify` 提示用户处理候选;常驻 service 没有可信外部
|
|
11
|
+
* InteractionTarget,不能调用 `host.ask`。
|
|
12
|
+
*
|
|
13
|
+
* 设计约束(全部来自 RFC-327/RFC-344 的既有规则,非本模块新增):
|
|
14
|
+
* - **不触发抽取/模型/工作**:tick 是"看一眼候选并询问/提醒",不是"干活";要产生工作仍须
|
|
15
|
+
* 走 capability-gated `startJob`(本报告器刻意不用)。
|
|
16
|
+
* - **默认只读**:常规路径只读候选并通知,不在后台将候选标记为用户已处理。
|
|
17
|
+
* - **静默是常态**:没有新候选就什么都不发。定时任务最容易变成噪音源,故默认沉默。
|
|
18
|
+
* - **幂等**:以 `{scheduleId, occurrenceId}` 去重(at-least-once 投递下重投不重复通知)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 已报告过的候选 id + 最近一次 occurrence,持久化在 service 自己的 scoped storage。 */
|
|
22
|
+
export const DIGEST_STATE_ID = 'candidate-digest-state'
|
|
23
|
+
|
|
24
|
+
export interface DigestState {
|
|
25
|
+
/** 已经通知过用户的候选 id——避免同一个候选每小时提醒一次。 */
|
|
26
|
+
reportedIds: string[]
|
|
27
|
+
/** 最近处理过的 occurrence(幂等键,防 at-least-once 重投重复通知)。 */
|
|
28
|
+
lastOccurrenceId?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 候选被视为"值得报告"的最小簇规模——低于此值信噪比太差。 */
|
|
32
|
+
export const DIGEST_MIN_CLUSTER_SIZE = 3
|
|
33
|
+
|
|
34
|
+
/** 单次通知最多列举的候选数(正文长度受宿主 500 字符上限约束)。 */
|
|
35
|
+
const DIGEST_MAX_LISTED = 3
|
|
36
|
+
|
|
37
|
+
/** 报告器状态的读写面(与 CandidateStorage 同构,便于共用 ctx.storage)。 */
|
|
38
|
+
export type DigestStorage = CandidateStorage
|
|
39
|
+
|
|
40
|
+
export async function loadDigestState(storage: DigestStorage): Promise<DigestState> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await storage.load(DIGEST_STATE_ID)
|
|
43
|
+
if (!raw) return { reportedIds: [] }
|
|
44
|
+
const reportedIds = Array.isArray(raw['reportedIds']) ? (raw['reportedIds'] as string[]) : []
|
|
45
|
+
const lastOccurrenceId =
|
|
46
|
+
typeof raw['lastOccurrenceId'] === 'string' ? (raw['lastOccurrenceId'] as string) : undefined
|
|
47
|
+
return lastOccurrenceId ? { reportedIds, lastOccurrenceId } : { reportedIds }
|
|
48
|
+
} catch {
|
|
49
|
+
// 坏存储不崩服务(同 candidate-store 的 R4 纪律)——退化为"什么都没报告过",
|
|
50
|
+
// 最坏后果是重复通知一次,不是丢数据。
|
|
51
|
+
return { reportedIds: [] }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 挑出值得报告的候选:规模达标、未被用户拒绝、且此前没报告过。
|
|
57
|
+
*
|
|
58
|
+
* `dismissedAt` 的候选被永久排除——用户已经说过"不要",定时提醒再把它翻出来是骚扰
|
|
59
|
+
* (R9 冷却语义在报告面的延伸)。
|
|
60
|
+
*/
|
|
61
|
+
export function selectReportable(
|
|
62
|
+
candidates: readonly SkillCandidate[],
|
|
63
|
+
reportedIds: readonly string[],
|
|
64
|
+
): SkillCandidate[] {
|
|
65
|
+
const reported = new Set(reportedIds)
|
|
66
|
+
return candidates
|
|
67
|
+
.filter((c) => c.size >= DIGEST_MIN_CLUSTER_SIZE && c.dismissedAt === undefined && !reported.has(c.id))
|
|
68
|
+
.sort((a, b) => b.size - a.size)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 组装通知正文——纯文本、有界;宿主还会再做一次控制字符剥离与截断。 */
|
|
72
|
+
export function formatDigestMessage(reportable: readonly SkillCandidate[]): string {
|
|
73
|
+
const listed = reportable.slice(0, DIGEST_MAX_LISTED)
|
|
74
|
+
const lines = listed.map((c) => `· ${c.taskSignature}(重复 ${c.size} 次)`)
|
|
75
|
+
const more = reportable.length - listed.length
|
|
76
|
+
const tail = more > 0 ? ` 等 ${reportable.length} 项` : ''
|
|
77
|
+
return `发现可沉淀为技能的重复任务${tail}:\n${lines.join('\n')}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
export interface DigestDeps {
|
|
82
|
+
storage: DigestStorage
|
|
83
|
+
/** 宿主注入的通知能力;未授予 `user.notify` 时为 undefined(fail-closed)。 */
|
|
84
|
+
notify?: (input: { level: 'info' | 'warning' | 'error'; code: string; message: string }) => Promise<void>
|
|
85
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type DigestOutcome =
|
|
89
|
+
| 'notified'
|
|
90
|
+
| 'nothing-to-report'
|
|
91
|
+
| 'duplicate-occurrence'
|
|
92
|
+
| 'missing-capability'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 处理一次 `schedule.tick`:读候选 → 挑出值得说的 → 通知 → 记账。
|
|
96
|
+
*
|
|
97
|
+
* 返回值供测试与诊断使用;服务侧不据此分支(tick 是尽力而为的旁路信号)。
|
|
98
|
+
*/
|
|
99
|
+
export async function runCandidateDigest(
|
|
100
|
+
deps: DigestDeps,
|
|
101
|
+
tick: { scheduleId: string; occurrenceId: string },
|
|
102
|
+
): Promise<DigestOutcome> {
|
|
103
|
+
const { storage, notify, logger } = deps
|
|
104
|
+
if (!notify) {
|
|
105
|
+
// 未授予 user.notify——静默跳过而非报错:能力缺失是配置事实,不是运行故障。
|
|
106
|
+
return 'missing-capability'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const state = await loadDigestState(storage)
|
|
110
|
+
|
|
111
|
+
// 幂等:at-least-once 投递下同一 occurrence 可能重投,重复处理会让用户收到两次相同通知。
|
|
112
|
+
if (state.lastOccurrenceId === tick.occurrenceId) return 'duplicate-occurrence'
|
|
113
|
+
|
|
114
|
+
const { items } = await loadCandidates(storage)
|
|
115
|
+
const reportable = selectReportable(items, state.reportedIds)
|
|
116
|
+
|
|
117
|
+
// 静默是常态:没有新候选就什么都不做(连状态都不必写,除了 occurrence 记账)。
|
|
118
|
+
if (reportable.length === 0) {
|
|
119
|
+
await saveDigestState(storage, { ...state, lastOccurrenceId: tick.occurrenceId })
|
|
120
|
+
return 'nothing-to-report'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await notify!({
|
|
124
|
+
level: 'info',
|
|
125
|
+
code: 'skill-inductor.candidates-ready',
|
|
126
|
+
message: formatDigestMessage(reportable),
|
|
127
|
+
})
|
|
128
|
+
logger.info(`skill-inductor digest: notified ${reportable.length} candidate(s)`)
|
|
129
|
+
|
|
130
|
+
await saveDigestState(storage, {
|
|
131
|
+
// 只记录**已报告**的 id——被过滤掉的(规模不够/已拒绝)不入账,
|
|
132
|
+
// 否则它们将来长大或被恢复时会因"报告过"而永不再提醒。
|
|
133
|
+
reportedIds: [...state.reportedIds, ...reportable.map((c) => c.id)],
|
|
134
|
+
lastOccurrenceId: tick.occurrenceId,
|
|
135
|
+
})
|
|
136
|
+
return 'notified'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function saveDigestState(storage: DigestStorage, state: DigestState): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
await storage.save(DIGEST_STATE_ID, { ...state })
|
|
142
|
+
} catch {
|
|
143
|
+
// 写失败只影响幂等精度(下次可能重复通知一条),不影响正确性——不上抛以免
|
|
144
|
+
// 让一次记账失败被 worker 记成服务崩溃。
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* candidate-store.ts —— RFC-318 M3:候选的持久化、合并与冷却。
|
|
3
|
+
*
|
|
4
|
+
* 与 episode 记录分开存(独立记录 id):两者读写节奏不同——episode 每次抽取都写,
|
|
5
|
+
* candidates 只在成簇时写;合并成一条会放大写冲突面,也让 M4a 的宿主读取器被迫读入
|
|
6
|
+
* 全部 episode 才能拿到候选。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SkillCandidate } from './candidate-detector'
|
|
10
|
+
|
|
11
|
+
/** storage 记录 id。 */
|
|
12
|
+
export const CANDIDATE_RECORD_ID = 'candidates'
|
|
13
|
+
|
|
14
|
+
/** 候选集合上限(R6 治理内建)——超出 FIFO 丢弃最旧的。 */
|
|
15
|
+
export const MAX_CANDIDATES = 50
|
|
16
|
+
|
|
17
|
+
export interface CandidateStorage {
|
|
18
|
+
save(id: string, data: Record<string, unknown>): Promise<void>
|
|
19
|
+
load(id: string): Promise<Record<string, unknown> | null>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CandidateRecord {
|
|
23
|
+
items: SkillCandidate[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 读取候选记录。脏数据/异常一律退化为空(R4:坏存储不崩服务)。 */
|
|
27
|
+
export async function loadCandidates(storage: CandidateStorage): Promise<CandidateRecord> {
|
|
28
|
+
let raw: Record<string, unknown> | null
|
|
29
|
+
try {
|
|
30
|
+
raw = await storage.load(CANDIDATE_RECORD_ID)
|
|
31
|
+
} catch {
|
|
32
|
+
return { items: [] }
|
|
33
|
+
}
|
|
34
|
+
if (!raw) return { items: [] }
|
|
35
|
+
const items = Array.isArray(raw['items']) ? (raw['items'] as SkillCandidate[]) : []
|
|
36
|
+
return { items }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 把新检测出的候选并入既有集合。
|
|
41
|
+
*
|
|
42
|
+
* **核心不变式(R9)**:既有候选的 `dismissedAt` **必须保留**。新一轮检测总会重新产出
|
|
43
|
+
* 同一个簇(id 稳定),若直接覆盖就等于每次抽取都把用户的拒绝抹掉——冷却形同虚设。
|
|
44
|
+
* 其余字段(size/lastAt/sessionIds/tags)用新值:簇在成长,这些本就该更新。
|
|
45
|
+
*/
|
|
46
|
+
export function mergeCandidates(
|
|
47
|
+
existing: readonly SkillCandidate[],
|
|
48
|
+
detected: readonly SkillCandidate[],
|
|
49
|
+
): SkillCandidate[] {
|
|
50
|
+
const preservedFieldsById = new Map<string, { dismissedAt?: number; appliedAt?: number; usageCount?: number }>()
|
|
51
|
+
for (const c of existing) {
|
|
52
|
+
const preserved: { dismissedAt?: number; appliedAt?: number; usageCount?: number } = {}
|
|
53
|
+
if (typeof c.dismissedAt === 'number') preserved.dismissedAt = c.dismissedAt
|
|
54
|
+
if (typeof c.appliedAt === 'number') preserved.appliedAt = c.appliedAt
|
|
55
|
+
if (typeof c.usageCount === 'number') preserved.usageCount = c.usageCount
|
|
56
|
+
if (Object.keys(preserved).length > 0) preservedFieldsById.set(c.id, preserved)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const merged: SkillCandidate[] = []
|
|
60
|
+
const detectedIds = new Set<string>()
|
|
61
|
+
for (const c of detected) {
|
|
62
|
+
detectedIds.add(c.id)
|
|
63
|
+
const preserved = preservedFieldsById.get(c.id)
|
|
64
|
+
merged.push(preserved ? { ...c, ...preserved } : c)
|
|
65
|
+
}
|
|
66
|
+
// 保留本轮没再检出的既有候选(窗口滑动会让老簇暂时落选,但用户的拒绝记录不该随之
|
|
67
|
+
// 消失——否则老簇重新进窗口时又会被当成全新候选来问)。
|
|
68
|
+
for (const c of existing) {
|
|
69
|
+
if (!detectedIds.has(c.id)) merged.push(c)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const overflow = merged.length - MAX_CANDIDATES
|
|
73
|
+
return overflow > 0 ? merged.slice(overflow) : merged
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 落盘。失败静默(R4)——下次抽取会重算重写。 */
|
|
77
|
+
export async function saveCandidates(
|
|
78
|
+
storage: CandidateStorage,
|
|
79
|
+
record: CandidateRecord,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await storage.save(CANDIDATE_RECORD_ID, { items: record.items })
|
|
83
|
+
return true
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* episode-store.ts —— RFC-318 M2:episode 持久化与治理。
|
|
3
|
+
*
|
|
4
|
+
* **治理内建(R6)**:上限 + FIFO 淘汰从第一天就在,而不是等集合膨胀后补救——RFC-284
|
|
5
|
+
* 的直接教训(lesson 集合无界增长到 154 条 / 每轮 49k tokens 才回头做淘汰治理)。
|
|
6
|
+
*
|
|
7
|
+
* **不直接碰文件系统**:只经 `PluginScopedStorageShape` 契约。service worker 的写面已被
|
|
8
|
+
* execArgv 收窄到自己的 plugin-state 目录(R7),绕过契约自己 `fs.writeFile` 既违反
|
|
9
|
+
* 隔离语义、也会在写面外直接失败。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Episode } from './episode'
|
|
13
|
+
|
|
14
|
+
/** storage 记录 id(单条记录承载全部 episode + 游标)。 */
|
|
15
|
+
export const EPISODE_RECORD_ID = 'episodes'
|
|
16
|
+
|
|
17
|
+
/** episode 集合上限——超出即 FIFO 丢弃最旧的。 */
|
|
18
|
+
export const MAX_EPISODES = 200
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 游标表上限(审核发现的第二个无界增长点)。
|
|
22
|
+
*
|
|
23
|
+
* 每个见过的会话都会留下一条游标,只增不减——长期运行会攒出数千条目。上限设 500:
|
|
24
|
+
* 远大于任何真实的"仍在活跃"会话数,被淘汰的必然是早已结束的老会话,代价仅是"万一
|
|
25
|
+
* 它复活,重抽一小段轨迹"(无数据损坏,且立刻被 MIN_MESSAGES 门挡住多数情况)。
|
|
26
|
+
*
|
|
27
|
+
* 为什么不做"按会话是否存活清理":插件侧没有会话生命周期视图(宿主也没给这个能力,
|
|
28
|
+
* 也不该给——那是能力面的扩张)。纯本地的容量上限是这里唯一诚实的治理手段。
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_CURSORS = 500
|
|
31
|
+
|
|
32
|
+
/** 存储契约的最小结构(只依赖用到的两个方法,不绑定完整 SDK 类型)。 */
|
|
33
|
+
export interface EpisodeStorage {
|
|
34
|
+
save(id: string, data: Record<string, unknown>): Promise<void>
|
|
35
|
+
load(id: string): Promise<Record<string, unknown> | null>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EpisodeRecord {
|
|
39
|
+
items: Episode[]
|
|
40
|
+
/**
|
|
41
|
+
* 每个会话下次该从哪个消息下标继续读(避免重复抽取同一段)。
|
|
42
|
+
*
|
|
43
|
+
* **有界(R6)**:见 `MAX_CURSORS`——按插入顺序 FIFO 淘汰。JS 对象保持字符串键的插入
|
|
44
|
+
* 顺序,故"最先记录的会话"就是最旧的。
|
|
45
|
+
*/
|
|
46
|
+
cursor: Record<string, number>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emptyRecord(): EpisodeRecord {
|
|
50
|
+
return { items: [], cursor: {} }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 读取记录。**任何异常/脏数据都退化为空记录**(R4)——存储损坏不该让服务崩溃,
|
|
55
|
+
* 大不了从头攒。
|
|
56
|
+
*/
|
|
57
|
+
export async function loadRecord(storage: EpisodeStorage): Promise<EpisodeRecord> {
|
|
58
|
+
let raw: Record<string, unknown> | null
|
|
59
|
+
try {
|
|
60
|
+
raw = await storage.load(EPISODE_RECORD_ID)
|
|
61
|
+
} catch {
|
|
62
|
+
return emptyRecord()
|
|
63
|
+
}
|
|
64
|
+
if (!raw) return emptyRecord()
|
|
65
|
+
const items = Array.isArray(raw['items']) ? (raw['items'] as Episode[]) : []
|
|
66
|
+
const cursorRaw = raw['cursor']
|
|
67
|
+
const cursor: Record<string, number> = {}
|
|
68
|
+
if (cursorRaw && typeof cursorRaw === 'object' && !Array.isArray(cursorRaw)) {
|
|
69
|
+
for (const [k, v] of Object.entries(cursorRaw as Record<string, unknown>)) {
|
|
70
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) cursor[k] = v
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { items, cursor }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 追加一条 episode 并应用治理上限。纯函数(不落盘)——便于单测直接验治理语义。
|
|
78
|
+
*
|
|
79
|
+
* FIFO 而非 LRU:episode 的价值随时间衰减(老的执行路径可能已因代码演进失效),
|
|
80
|
+
* 且"最近做过的同类任务"正是 M3 想聚的簇——按时间保留天然对齐检测窗口。
|
|
81
|
+
*/
|
|
82
|
+
export function appendEpisode(record: EpisodeRecord, episode: Episode): EpisodeRecord {
|
|
83
|
+
const items = [...record.items, episode]
|
|
84
|
+
const overflow = items.length - MAX_EPISODES
|
|
85
|
+
return {
|
|
86
|
+
items: overflow > 0 ? items.slice(overflow) : items,
|
|
87
|
+
cursor: record.cursor,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 更新某会话的读取游标,并应用游标表上限(FIFO)。
|
|
93
|
+
*
|
|
94
|
+
* 更新既有会话时**先删后插**,使其回到插入序末尾——否则一个长期活跃的会话会因为"最早
|
|
95
|
+
* 被记录"而在扩容淘汰时被误删,导致它反复重抽(正好淘汰掉最不该淘汰的那个)。
|
|
96
|
+
*/
|
|
97
|
+
export function advanceCursor(
|
|
98
|
+
record: EpisodeRecord,
|
|
99
|
+
sessionId: string,
|
|
100
|
+
nextIndex: number,
|
|
101
|
+
): EpisodeRecord {
|
|
102
|
+
const cursor: Record<string, number> = { ...record.cursor }
|
|
103
|
+
delete cursor[sessionId]
|
|
104
|
+
cursor[sessionId] = nextIndex
|
|
105
|
+
|
|
106
|
+
const keys = Object.keys(cursor)
|
|
107
|
+
if (keys.length > MAX_CURSORS) {
|
|
108
|
+
for (const stale of keys.slice(0, keys.length - MAX_CURSORS)) delete cursor[stale]
|
|
109
|
+
}
|
|
110
|
+
return { items: record.items, cursor }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 落盘。失败静默(R4)——调用方已在内存里推进了状态,下次 notify 会重试。 */
|
|
114
|
+
export async function saveRecord(storage: EpisodeStorage, record: EpisodeRecord): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
// storage.save 只接受对象,故 items 数组包在记录对象里(契约约束,非设计偏好)。
|
|
117
|
+
await storage.save(EPISODE_RECORD_ID, { items: record.items, cursor: record.cursor })
|
|
118
|
+
return true
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|