pi-okf-memory 0.1.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/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +265 -0
- package/cordis.patch.yml +6 -0
- package/docs/graph-demo.png +0 -0
- package/lib/capture.js +53 -0
- package/lib/client.js +417 -0
- package/lib/client.js.map +1 -0
- package/lib/concept.js +264 -0
- package/lib/dedupe.js +149 -0
- package/lib/graph.js +95 -0
- package/lib/index.js +381 -0
- package/lib/learning.js +185 -0
- package/lib/memory.js +135 -0
- package/lib/recall.js +47 -0
- package/lib/store.js +217 -0
- package/package.json +89 -0
- package/src/client/index.tsx +232 -0
- package/src/pi/graph-html.ts +300 -0
- package/src/pi/index.ts +383 -0
- package/src/server/capture.ts +52 -0
- package/src/server/concept.ts +302 -0
- package/src/server/dedupe.ts +164 -0
- package/src/server/dsh-tools.d.ts +11 -0
- package/src/server/graph.ts +135 -0
- package/src/server/index.ts +376 -0
- package/src/server/learning.ts +219 -0
- package/src/server/memory.ts +157 -0
- package/src/server/recall.ts +58 -0
- package/src/server/store.ts +255 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-html.ts — 把记忆图谱渲染成自包含交互式 HTML。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 web 面板:pi 是 TUI/RPC 运行时,没有 dsh 的 webServer 注入点,
|
|
5
|
+
* 因此把 src/client/index.tsx 的 canvas 图谱逻辑(环形布局 / 滚轮缩放 / 拖拽平移与拖节点 /
|
|
6
|
+
* 悬停详情 / 搜索命中脉冲 + BFS 神经传导)平移为无依赖的独立页面,
|
|
7
|
+
* 由 /memory-graph 命令生成到临时目录并用系统浏览器打开。
|
|
8
|
+
*
|
|
9
|
+
* 输出不依赖任何 CDN 或构建产物,单文件可离线打开、可分享。
|
|
10
|
+
*/
|
|
11
|
+
import type { GraphData } from '../server/graph.js'
|
|
12
|
+
|
|
13
|
+
/** 类型配色(与 dsh client 保持一致) */
|
|
14
|
+
const TYPE_COLORS: Record<string, string> = {
|
|
15
|
+
Fact: '#2f7bff', Preference: '#ffd400', Decision: '#ff2d55', Method: '#00e66e',
|
|
16
|
+
Insight: '#c04dff', Idea: '#ff7a00', Lesson: '#c8d1dd', TechChoice: '#00e5ff', Other: '#9aa7b8',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** JSON 内联进 <script> 时的转义(防 `</script>` 提前闭合) */
|
|
20
|
+
function inlineJson(value: unknown): string {
|
|
21
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028|\u2029/g, (m) => (m === '\u2028' ? '\\u2028' : '\\u2029'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeHtml(s: unknown): string {
|
|
25
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
|
26
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string
|
|
27
|
+
))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 生成图谱 HTML。
|
|
32
|
+
* @param g buildGraph() 的输出
|
|
33
|
+
*/
|
|
34
|
+
export function renderGraphHtml(g: GraphData): string {
|
|
35
|
+
const canvasData = { nodes: g.nodes, edges: g.edges.map((e) => ({ source: e.source, target: e.target })) }
|
|
36
|
+
|
|
37
|
+
// 顶部统计(服务端渲染,便于无 JS 时也能读)
|
|
38
|
+
const byType = new Map<string, number>()
|
|
39
|
+
for (const n of g.nodes) byType.set(n.type, (byType.get(n.type) || 0) + 1)
|
|
40
|
+
const typeRows = [...byType.entries()]
|
|
41
|
+
.sort((a, b) => b[1] - a[1])
|
|
42
|
+
.map(([t, c]) => `<span class="chip"><i style="background:${TYPE_COLORS[t] || TYPE_COLORS.Other}"></i>${escapeHtml(t)} <b>${c}</b></span>`)
|
|
43
|
+
.join('')
|
|
44
|
+
|
|
45
|
+
return `<!doctype html>
|
|
46
|
+
<html lang="zh-CN">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
50
|
+
<title>OKF 记忆图谱 · ${g.meta.totalConcepts} 节点</title>
|
|
51
|
+
<style>
|
|
52
|
+
* { box-sizing: border-box; }
|
|
53
|
+
html, body { margin: 0; height: 100%; background: #0d1725; color: #dbe7f3;
|
|
54
|
+
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; }
|
|
55
|
+
header { padding: 14px 18px 10px; border-bottom: 1px solid rgba(120,160,200,.14); }
|
|
56
|
+
h1 { margin: 0 0 8px; font-size: 15px; font-weight: 600; letter-spacing: .2px; }
|
|
57
|
+
h1 small { font-weight: 400; color: #7d97b0; margin-left: 8px; font-size: 12px; }
|
|
58
|
+
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 11px; color: #8aa4bd; }
|
|
59
|
+
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px;
|
|
60
|
+
border-radius: 20px; background: rgba(255,255,255,.05); }
|
|
61
|
+
.chip i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
|
62
|
+
.chip b { color: #dbe7f3; font-weight: 600; }
|
|
63
|
+
.bar { display: flex; gap: 10px; align-items: center; padding: 10px 18px; }
|
|
64
|
+
#q { flex: 1; padding: 9px 12px; border-radius: 8px; border: 1px solid rgba(120,160,200,.3);
|
|
65
|
+
background: rgba(20,32,46,.8); color: #dbe7f3; font-size: 13px; outline: none; }
|
|
66
|
+
#q:focus { border-color: rgba(126,195,255,.55); }
|
|
67
|
+
#hint { font-size: 11px; color: #6f8ba5; white-space: nowrap; }
|
|
68
|
+
.stage { position: relative; padding: 0 18px 18px; }
|
|
69
|
+
canvas { width: 100%; height: calc(100vh - 190px); display: block; border-radius: 10px; cursor: crosshair; }
|
|
70
|
+
#tip { position: absolute; top: 10px; right: 30px; max-width: 340px; padding: 10px 13px;
|
|
71
|
+
border-radius: 9px; background: rgba(10,18,28,.97); font-size: 12px; line-height: 1.5;
|
|
72
|
+
box-shadow: 0 8px 26px rgba(0,0,0,.55); display: none; }
|
|
73
|
+
#tip .t { font-weight: 600; font-size: 13px; }
|
|
74
|
+
#tip .ty { font-size: 10px; padding: 1px 7px; border-radius: 8px; margin-left: 6px; color: #fff; }
|
|
75
|
+
#tip .dim { color: #8aa4bd; margin-top: 4px; }
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<header>
|
|
80
|
+
<h1>OKF 记忆图谱<small>${escapeHtml(g.meta.root)}</small></h1>
|
|
81
|
+
<div class="meta">
|
|
82
|
+
<span class="chip">节点 <b>${g.nodes.length}</b></span>
|
|
83
|
+
<span class="chip">边 <b>${g.edges.length}</b></span>
|
|
84
|
+
<span class="chip">生成于 <b>${escapeHtml(String(g.meta.generatedAt).slice(0, 19).replace('T', ' '))}</b></span>
|
|
85
|
+
${typeRows}
|
|
86
|
+
</div>
|
|
87
|
+
</header>
|
|
88
|
+
<div class="bar">
|
|
89
|
+
<input id="q" placeholder="🔍 命中记忆(搜标题 / 类型 / 标签)…" autocomplete="off">
|
|
90
|
+
<span id="hint">滚轮缩放 · 拖拽平移 · 拖节点 · 悬停详情</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="stage">
|
|
93
|
+
<canvas id="c"></canvas>
|
|
94
|
+
<div id="tip"></div>
|
|
95
|
+
</div>
|
|
96
|
+
<script>
|
|
97
|
+
const GRAPH = ${inlineJson(canvasData)};
|
|
98
|
+
const TYPE_COLORS = ${inlineJson(TYPE_COLORS)};
|
|
99
|
+
|
|
100
|
+
const canvas = document.getElementById('c');
|
|
101
|
+
const ctx = canvas.getContext('2d');
|
|
102
|
+
const tip = document.getElementById('tip');
|
|
103
|
+
const qInput = document.getElementById('q');
|
|
104
|
+
|
|
105
|
+
const view = { zoom: 1, panX: 0, panY: 0 };
|
|
106
|
+
let drag = { id: null, offX: 0, offY: 0, panning: false, lastX: 0, lastY: 0 };
|
|
107
|
+
let hoverId = null;
|
|
108
|
+
let query = '';
|
|
109
|
+
|
|
110
|
+
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }
|
|
111
|
+
function hexToRgba(hex, a) {
|
|
112
|
+
const c = hex.replace('#', '');
|
|
113
|
+
return 'rgba(' + parseInt(c.slice(0,2),16) + ',' + parseInt(c.slice(2,4),16) + ',' + parseInt(c.slice(4,6),16) + ',' + a + ')';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 环形初始布局(与 dsh client 一致;拖拽后不再自动回弹) ──
|
|
117
|
+
const maxW = Math.max(...GRAPH.nodes.map(n => n.weight), 1);
|
|
118
|
+
const N = GRAPH.nodes.length || 1;
|
|
119
|
+
const R = Math.max(120, Math.min(300, 60 + N * 30));
|
|
120
|
+
const layout = GRAPH.nodes.map((node, i) => {
|
|
121
|
+
const h = hash(node.id);
|
|
122
|
+
const ang = (i / N) * Math.PI * 2;
|
|
123
|
+
const r = R * (0.6 + ((h % 100) / 100) * 0.5);
|
|
124
|
+
return Object.assign({}, node, {
|
|
125
|
+
x: Math.cos(ang) * r, y: Math.sin(ang) * r,
|
|
126
|
+
r: 8 + Math.sqrt(node.weight / maxW) * 20,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
const byId = new Map(layout.map(n => [n.id, n]));
|
|
130
|
+
|
|
131
|
+
function matches(n, q) {
|
|
132
|
+
const t = q.trim().toLowerCase();
|
|
133
|
+
if (!t) return false;
|
|
134
|
+
return (n.title + ' ' + n.type + ' ' + (n.tags || []).join(' ')).toLowerCase().includes(t);
|
|
135
|
+
}
|
|
136
|
+
// BFS 神经传导:命中节点的关联节点逐级激活
|
|
137
|
+
function activateHits(hits) {
|
|
138
|
+
const act = new Set(hits);
|
|
139
|
+
const adj = new Map();
|
|
140
|
+
GRAPH.edges.forEach(e => {
|
|
141
|
+
if (!adj.has(e.source)) adj.set(e.source, []);
|
|
142
|
+
if (!adj.has(e.target)) adj.set(e.target, []);
|
|
143
|
+
adj.get(e.source).push(e.target);
|
|
144
|
+
adj.get(e.target).push(e.source);
|
|
145
|
+
});
|
|
146
|
+
const queue = [...hits];
|
|
147
|
+
while (queue.length) {
|
|
148
|
+
const cur = queue.shift();
|
|
149
|
+
(adj.get(cur) || []).forEach(nb => { if (!act.has(nb)) { act.add(nb); queue.push(nb); } });
|
|
150
|
+
}
|
|
151
|
+
return act;
|
|
152
|
+
}
|
|
153
|
+
function hitTest(px, py, cw, ch) {
|
|
154
|
+
const wx = (px - cw / 2 - view.panX) / view.zoom;
|
|
155
|
+
const wy = (py - ch / 2 - view.panY) / view.zoom;
|
|
156
|
+
for (let i = layout.length - 1; i >= 0; i--) {
|
|
157
|
+
const n = layout[i];
|
|
158
|
+
const dx = n.x - wx, dy = n.y - wy;
|
|
159
|
+
if (dx * dx + dy * dy < (n.r + 6) * (n.r + 6)) return n;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function draw() {
|
|
165
|
+
const dpr = window.devicePixelRatio || 1;
|
|
166
|
+
const W = canvas.clientWidth || 800, H = canvas.clientHeight || 500;
|
|
167
|
+
canvas.width = W * dpr; canvas.height = H * dpr;
|
|
168
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
169
|
+
|
|
170
|
+
const tx = x => W / 2 + x * view.zoom + view.panX;
|
|
171
|
+
const ty = y => H / 2 + y * view.zoom + view.panY;
|
|
172
|
+
|
|
173
|
+
ctx.fillStyle = '#13233a'; ctx.fillRect(0, 0, W, H);
|
|
174
|
+
|
|
175
|
+
const hits = new Set();
|
|
176
|
+
if (query.trim()) GRAPH.nodes.forEach(n => { if (matches(n, query)) hits.add(n.id); });
|
|
177
|
+
const active = activateHits(hits);
|
|
178
|
+
|
|
179
|
+
// 边
|
|
180
|
+
GRAPH.edges.forEach(e => {
|
|
181
|
+
const a = byId.get(e.source), b = byId.get(e.target);
|
|
182
|
+
if (!a || !b) return;
|
|
183
|
+
const isActive = active.has(e.source) || active.has(e.target);
|
|
184
|
+
ctx.beginPath(); ctx.moveTo(tx(a.x), ty(a.y)); ctx.lineTo(tx(b.x), ty(b.y));
|
|
185
|
+
ctx.strokeStyle = isActive ? 'rgba(126,195,255,.5)' : 'rgba(120,160,200,.18)';
|
|
186
|
+
ctx.lineWidth = 1 / view.zoom; ctx.stroke();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const t = performance.now() / 700;
|
|
190
|
+
layout.forEach(n => {
|
|
191
|
+
const c = TYPE_COLORS[n.type] || TYPE_COLORS.Other;
|
|
192
|
+
const isHit = hits.has(n.id), isActive = active.has(n.id);
|
|
193
|
+
const rr = n.r * view.zoom;
|
|
194
|
+
const x = tx(n.x), y = ty(n.y);
|
|
195
|
+
|
|
196
|
+
// 命中脉冲光环
|
|
197
|
+
if (isHit) {
|
|
198
|
+
const tp = (t + (hash(n.id) % 10) / 10) % 1;
|
|
199
|
+
ctx.beginPath(); ctx.arc(x, y, rr * (1.5 + tp * 1.8), 0, Math.PI * 2);
|
|
200
|
+
ctx.strokeStyle = hexToRgba(c, 0.8 * (1 - tp)); ctx.lineWidth = 2.5 / view.zoom; ctx.stroke();
|
|
201
|
+
}
|
|
202
|
+
// 激活辉光
|
|
203
|
+
if (isHit || isActive) {
|
|
204
|
+
const rad = rr * (isHit ? 2.9 : 2.1);
|
|
205
|
+
const rg = ctx.createRadialGradient(x, y, rr * 0.2, x, y, rad);
|
|
206
|
+
rg.addColorStop(0, hexToRgba(c, isHit ? 0.8 : 0.45));
|
|
207
|
+
rg.addColorStop(1, 'transparent');
|
|
208
|
+
ctx.beginPath(); ctx.arc(x, y, rad, 0, Math.PI * 2); ctx.fillStyle = rg; ctx.fill();
|
|
209
|
+
}
|
|
210
|
+
// 节点本体
|
|
211
|
+
ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2);
|
|
212
|
+
ctx.fillStyle = isActive ? (isHit ? '#fff' : c) : '#13233a';
|
|
213
|
+
ctx.globalAlpha = isActive ? 0.95 : 1; ctx.fill(); ctx.globalAlpha = 1;
|
|
214
|
+
ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2);
|
|
215
|
+
ctx.strokeStyle = isHit ? '#fff' : (isActive ? c : '#33506a');
|
|
216
|
+
ctx.lineWidth = (isHit ? 3.2 : 1.2) / view.zoom; ctx.stroke();
|
|
217
|
+
|
|
218
|
+
// archived 虚线描边(状态可见)
|
|
219
|
+
if (n.state === 'inactive') {
|
|
220
|
+
ctx.setLineDash([3 / view.zoom, 3 / view.zoom]);
|
|
221
|
+
ctx.beginPath(); ctx.arc(x, y, rr + 3, 0, Math.PI * 2);
|
|
222
|
+
ctx.strokeStyle = 'rgba(200,209,221,.5)'; ctx.lineWidth = 1 / view.zoom; ctx.stroke();
|
|
223
|
+
ctx.setLineDash([]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 标签
|
|
227
|
+
ctx.fillStyle = isActive ? '#fff' : '#5f7d97';
|
|
228
|
+
ctx.font = (11 * Math.min(1.4, Math.max(0.8, view.zoom))) + 'px sans-serif';
|
|
229
|
+
ctx.textAlign = 'center';
|
|
230
|
+
ctx.fillText(String(n.title).slice(0, 10), x, y + rr + 12);
|
|
231
|
+
if (isHit) {
|
|
232
|
+
ctx.fillStyle = '#fff';
|
|
233
|
+
ctx.font = '700 ' + (11 * Math.max(0.8, view.zoom)) + 'px sans-serif';
|
|
234
|
+
ctx.fillText('⚡命中', x, y - rr - 9);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (query.trim()) {
|
|
239
|
+
document.getElementById('hint').textContent =
|
|
240
|
+
'命中 ' + hits.size + ' · 传导激活 ' + active.size + ' / ' + layout.length;
|
|
241
|
+
} else {
|
|
242
|
+
document.getElementById('hint').textContent = '滚轮缩放 · 拖拽平移 · 拖节点 · 悬停详情';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderTip(n) {
|
|
247
|
+
if (!n) { tip.style.display = 'none'; return; }
|
|
248
|
+
const c = TYPE_COLORS[n.type] || TYPE_COLORS.Other;
|
|
249
|
+
tip.style.display = 'block';
|
|
250
|
+
tip.style.border = '1px solid ' + c;
|
|
251
|
+
tip.innerHTML =
|
|
252
|
+
'<div><span class="t">' + n.title + '</span>' +
|
|
253
|
+
'<span class="ty" style="background:' + c + '66">' + n.type + '</span></div>' +
|
|
254
|
+
'<div class="dim">权重 ' + n.weight + ' · ' + n.state +
|
|
255
|
+
(n.lastAccessed ? ' · 最近 ' + String(n.lastAccessed).slice(0, 10) : '') + '</div>' +
|
|
256
|
+
(n.description ? '<div class="dim">' + n.description + '</div>' : '') +
|
|
257
|
+
((n.tags && n.tags.length) ? '<div class="dim">' + n.tags.map(x => '#' + x).join(' ') + '</div>' : '');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── 交互 ──
|
|
261
|
+
qInput.addEventListener('input', e => { query = e.target.value; });
|
|
262
|
+
canvas.addEventListener('wheel', e => {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
view.zoom = Math.min(4, Math.max(0.3, view.zoom * (e.deltaY < 0 ? 1.1 : 0.9)));
|
|
265
|
+
}, { passive: false });
|
|
266
|
+
canvas.addEventListener('mousedown', e => {
|
|
267
|
+
const rect = canvas.getBoundingClientRect();
|
|
268
|
+
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
269
|
+
const cw = canvas.clientWidth || rect.width, ch = canvas.clientHeight || rect.height;
|
|
270
|
+
const hit = hitTest(px, py, cw, ch);
|
|
271
|
+
if (hit) drag = { id: hit.id, offX: hit.x - px, offY: hit.y - py, panning: false, lastX: px, lastY: py };
|
|
272
|
+
else drag = { id: null, offX: 0, offY: 0, panning: true, lastX: px, lastY: py };
|
|
273
|
+
});
|
|
274
|
+
canvas.addEventListener('mousemove', e => {
|
|
275
|
+
const rect = canvas.getBoundingClientRect();
|
|
276
|
+
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
277
|
+
const cw = canvas.clientWidth || rect.width, ch = canvas.clientHeight || rect.height;
|
|
278
|
+
if (drag.id) {
|
|
279
|
+
const n = byId.get(drag.id);
|
|
280
|
+
if (n) {
|
|
281
|
+
n.x = (px - cw / 2 - view.panX) / view.zoom + drag.offX;
|
|
282
|
+
n.y = (py - ch / 2 - view.panY) / view.zoom + drag.offY;
|
|
283
|
+
}
|
|
284
|
+
} else if (drag.panning) {
|
|
285
|
+
view.panX += px - drag.lastX; view.panY += py - drag.lastY;
|
|
286
|
+
drag.lastX = px; drag.lastY = py;
|
|
287
|
+
}
|
|
288
|
+
const h = hitTest(px, py, cw, ch);
|
|
289
|
+
const id = h ? h.id : null;
|
|
290
|
+
if (id !== hoverId) { hoverId = id; renderTip(h); canvas.style.cursor = h ? 'grab' : 'crosshair'; }
|
|
291
|
+
});
|
|
292
|
+
window.addEventListener('mouseup', () => { drag = { id: null, offX: 0, offY: 0, panning: false, lastX: 0, lastY: 0 }; });
|
|
293
|
+
canvas.addEventListener('mouseleave', () => { hoverId = null; renderTip(null); });
|
|
294
|
+
window.addEventListener('resize', () => { view.zoom = 1; view.panX = 0; view.panY = 0; });
|
|
295
|
+
|
|
296
|
+
(function loop() { draw(); requestAnimationFrame(loop); })();
|
|
297
|
+
</script>
|
|
298
|
+
</body>
|
|
299
|
+
</html>`
|
|
300
|
+
}
|
package/src/pi/index.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi 适配层 — okf-memory 的 pi 扩展入口(src/pi/index.ts)。
|
|
3
|
+
*
|
|
4
|
+
* 与 src/server/index.ts(dsh 适配层)平级:两者共用 src/server/* 的运行时无关核心,
|
|
5
|
+
* 本文件只做「pi API ↔ 核心逻辑」的翻译。
|
|
6
|
+
*
|
|
7
|
+
* 映射关系:
|
|
8
|
+
* dsh ctx.tools.register(defineTool({parameters})) → pi.registerTool({parameters: Type.Object})
|
|
9
|
+
* dsh ctx.systemPrompt.section({name,order}) → pi.on("before_agent_start") 返回 systemPrompt
|
|
10
|
+
* dsh ctx.provide('okfMemory') → 删除(工具即 API)
|
|
11
|
+
* dsh ctx.inject(['webServer']) + /okf-graph 路由 → /memory-graph 命令导出静态 HTML
|
|
12
|
+
*
|
|
13
|
+
* 记忆库默认 ~/.pi/agent/okf-memory/(OKF_MEMORY_ROOT 可覆盖)。
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
16
|
+
import { Type } from 'typebox'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import { promises as fs } from 'node:fs'
|
|
20
|
+
|
|
21
|
+
// ── 运行时无关核心(零改动复用) ──
|
|
22
|
+
import { ensureRoot, scanBundle } from '../server/store.js'
|
|
23
|
+
import { search } from '../server/dedupe.js'
|
|
24
|
+
import { preload, recall } from '../server/recall.js'
|
|
25
|
+
import { loadMeta, recordSelect, recordSkip, consolidate, rank, startConsolidation } from '../server/learning.js'
|
|
26
|
+
import { MEMORY_DISCIPLINE, RECALL_GUIDE } from '../server/capture.js'
|
|
27
|
+
import { TYPE_VOCAB } from '../server/concept.js'
|
|
28
|
+
import { buildGraph } from '../server/graph.js'
|
|
29
|
+
import { rememberCore, forgetCore, type RememberResult, type ForgetResult } from '../server/memory.js'
|
|
30
|
+
import { renderGraphHtml } from './graph-html.js'
|
|
31
|
+
|
|
32
|
+
const EXT = 'okf-memory'
|
|
33
|
+
|
|
34
|
+
/** 记忆库根:OKF_MEMORY_ROOT > ~/.pi/agent/okf-memory/ */
|
|
35
|
+
function resolveRoot(): string {
|
|
36
|
+
const env = process.env.OKF_MEMORY_ROOT
|
|
37
|
+
if (env) return path.resolve(env)
|
|
38
|
+
return path.join(os.homedir(), '.pi', 'agent', 'okf-memory')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 工具返回值的统一包装(pi 用 content 数组 + details 结构化数据) */
|
|
42
|
+
function text(s: string, details: Record<string, unknown> = {}) {
|
|
43
|
+
return { content: [{ type: 'text' as const, text: s }], details }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 把根 index.md 整理成提示片段(模型每轮可见"库里有啥") */
|
|
47
|
+
async function buildIndexPrompt(root: string): Promise<string> {
|
|
48
|
+
try {
|
|
49
|
+
const idx = await fs.readFile(path.join(root, 'index.md'), 'utf8')
|
|
50
|
+
const concepts = await scanBundle(root)
|
|
51
|
+
return `记忆库共有 ${concepts.length} 个概念(路径即概念 ID)。库目录:\n${idx.slice(0, 3000)}`
|
|
52
|
+
} catch {
|
|
53
|
+
return 'OKF 记忆库为空或不可读。'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function okfMemoryExtension(pi: ExtensionAPI): void {
|
|
58
|
+
const root = resolveRoot()
|
|
59
|
+
/** 当前库摘要,写入后失效重建(避免每轮都扫盘) */
|
|
60
|
+
let digest = 'OKF 记忆库尚未初始化。'
|
|
61
|
+
|
|
62
|
+
const refreshDigest = async (): Promise<void> => {
|
|
63
|
+
digest = await buildIndexPrompt(root)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ────────────────────────────── 工具 ──────────────────────────────
|
|
67
|
+
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: 'okf_remember',
|
|
70
|
+
label: '记忆沉淀',
|
|
71
|
+
description:
|
|
72
|
+
'把一条新知识按 OKF v0.1 规范写入长期记忆库(概念文档 + index/log 更新)。' +
|
|
73
|
+
`type 词表:${TYPE_VOCAB.join('/')}。` +
|
|
74
|
+
'Decision/Insight 正文建议用 # 数据/# 分析/# 结论 三段式;TechChoice 用 ## Options 候选表 + ## Active。' +
|
|
75
|
+
'写入前自动去重:标题相同则更新/跳过,相近则返回建议。',
|
|
76
|
+
promptSnippet: '沉淀一条长期记忆(自动去重 + OKF 校验)',
|
|
77
|
+
promptGuidelines: [
|
|
78
|
+
'Use okf_remember when the user discloses a new durable fact, states a preference, makes a decision with rationale, corrects your understanding, or picks a technology — but search first with okf_search to avoid duplicates.',
|
|
79
|
+
'Always pass okf_remember a related array holding the concept IDs of concepts returned by okf_search that this new memory relates to — only related-written links become graph edges.',
|
|
80
|
+
'When okf_remember returns status "linked", resubmit with the returned similarTo value inside related instead of duplicating the body.',
|
|
81
|
+
'Do NOT call okf_remember for greetings, one-off tasks, or restating something already in the memory library.',
|
|
82
|
+
],
|
|
83
|
+
parameters: Type.Object({
|
|
84
|
+
title: Type.String({ description: '概念标题(简洁,一句话可懂)' }),
|
|
85
|
+
type: Type.String({ description: `概念类型,可选:${TYPE_VOCAB.join('/')}` }),
|
|
86
|
+
content: Type.String({ description: '结构化正文(Markdown,含 # 小节标题)。Decision/Insight 传三段式;TechChoice 传 Options 表与 Active' }),
|
|
87
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: '横切标签' })),
|
|
88
|
+
related: Type.Optional(Type.Array(Type.String(), { description: '相关概念 ID 列表(将互建交叉链接)' })),
|
|
89
|
+
}, { additionalProperties: false }),
|
|
90
|
+
async execute(_id, params) {
|
|
91
|
+
try {
|
|
92
|
+
const r: RememberResult = await rememberCore(root, {
|
|
93
|
+
title: params.title,
|
|
94
|
+
type: params.type,
|
|
95
|
+
tags: params.tags as string[] | undefined,
|
|
96
|
+
related: params.related as string[] | undefined,
|
|
97
|
+
}, params.content)
|
|
98
|
+
await refreshDigest()
|
|
99
|
+
const msg = r.status === 'created' ? `已沉淀记忆 ${r.conceptId}`
|
|
100
|
+
: r.status === 'updated' ? `已更新记忆 ${r.conceptId}`
|
|
101
|
+
: r.status === 'skipped' ? `跳过写入:${r.reason}`
|
|
102
|
+
: r.status === 'linked' ? `建议互补:${r.reason}`
|
|
103
|
+
: `记忆写入:${r.status}`
|
|
104
|
+
return text(msg, r as unknown as Record<string, unknown>)
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return text(`记忆写入失败:${String((e as Error).message || e)}`, { status: 'error', reason: String((e as Error).message || e) })
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
pi.registerTool({
|
|
112
|
+
name: 'okf_search',
|
|
113
|
+
label: '记忆召回',
|
|
114
|
+
description:
|
|
115
|
+
'检索 OKF 长期记忆库,按唤起评分(相关度 × 权重 × 近因)排序返回概念摘要。' +
|
|
116
|
+
'命中 TechChoice 类型时附加返回完整 Options 候选表,供技术选型三档规则展示。' +
|
|
117
|
+
'写入新记忆前必须先搜索去重。',
|
|
118
|
+
promptSnippet: '检索长期记忆(按 相关度 × 权重 × 近因 排序)',
|
|
119
|
+
promptGuidelines: [
|
|
120
|
+
'Use okf_search before answering questions that may depend on the user\'s stored background, preferences, or past decisions, and always before okf_remember.',
|
|
121
|
+
'If okf_search returns no match, tell the user the memory library has nothing — never invent a memory.',
|
|
122
|
+
],
|
|
123
|
+
parameters: Type.Object({
|
|
124
|
+
query: Type.String({ description: '检索关键词' }),
|
|
125
|
+
type: Type.Optional(Type.String({ description: '按类型过滤(如 TechChoice/Fact/Decision)' })),
|
|
126
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: '按标签过滤' })),
|
|
127
|
+
limit: Type.Optional(Type.Number({ description: '返回条数,默认 8' })),
|
|
128
|
+
}, { additionalProperties: false }),
|
|
129
|
+
async execute(_id, params) {
|
|
130
|
+
const raw = await search(root, String(params.query), {
|
|
131
|
+
type: params.type as string | undefined,
|
|
132
|
+
tags: params.tags as string[] | undefined,
|
|
133
|
+
limit: Math.min(Number(params.limit) || 8, 30),
|
|
134
|
+
})
|
|
135
|
+
const ranked = await rank(root, raw)
|
|
136
|
+
const results: Array<Record<string, unknown>> = []
|
|
137
|
+
for (const h of ranked) {
|
|
138
|
+
const item: Record<string, unknown> = { ...h }
|
|
139
|
+
if (h.type === 'TechChoice') {
|
|
140
|
+
// 附加候选表:读全文 Options 节
|
|
141
|
+
try {
|
|
142
|
+
const { readConcept } = await import('../server/store.js')
|
|
143
|
+
const c = await readConcept(root, h.conceptId)
|
|
144
|
+
const m = /## Options[\s\S]*?(?=## |$)/.exec(c.body || '')
|
|
145
|
+
item.options = m ? m[0].trim() : null
|
|
146
|
+
} catch { /* 读取失败则不带候选表 */ }
|
|
147
|
+
}
|
|
148
|
+
results.push(item)
|
|
149
|
+
}
|
|
150
|
+
const msg = results.length === 0
|
|
151
|
+
? '记忆库无匹配。'
|
|
152
|
+
: `检索到 ${results.length} 条记忆:\n` + results.map((r) => `- ${r.conceptId} (${r.type}, 权重 ${r.weight}) ${r.description}`).join('\n')
|
|
153
|
+
return text(msg, { count: results.length, results })
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
pi.registerTool({
|
|
158
|
+
name: 'okf_read',
|
|
159
|
+
label: '记忆精读',
|
|
160
|
+
description:
|
|
161
|
+
'读取记忆库中某个概念全文(含交叉链接),并记录一次使用反馈(权重 +0.1)。',
|
|
162
|
+
promptSnippet: '读取某条记忆全文并记录一次使用反馈',
|
|
163
|
+
parameters: Type.Object({
|
|
164
|
+
concept_id: Type.String({ description: '概念 ID(如 fact/门店布局,可省略 .md)' }),
|
|
165
|
+
}, { additionalProperties: false }),
|
|
166
|
+
async execute(_id, params) {
|
|
167
|
+
const id = String(params.concept_id).replace(/\.md$/, '')
|
|
168
|
+
const c = await recall(root, id)
|
|
169
|
+
return text(`# ${c.meta?.title || id} (${c.meta?.type || ''})\n\n${c.body || ''}`, {
|
|
170
|
+
conceptId: c.conceptId,
|
|
171
|
+
title: c.meta?.title || id,
|
|
172
|
+
type: c.meta?.type || '',
|
|
173
|
+
body: c.body || '',
|
|
174
|
+
links: c.links || [],
|
|
175
|
+
})
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
pi.registerTool({
|
|
180
|
+
name: 'okf_forget',
|
|
181
|
+
label: '记忆撤回',
|
|
182
|
+
description:
|
|
183
|
+
'从记忆库索引撤回一条概念(默认保留文件,可从 index/log 追溯;可选删除文件)。',
|
|
184
|
+
promptSnippet: '撤回一条记错的记忆',
|
|
185
|
+
parameters: Type.Object({
|
|
186
|
+
concept_id: Type.String({ description: '概念 ID' }),
|
|
187
|
+
delete_file: Type.Optional(Type.Boolean({ description: 'true 时同时删除文件(默认 false 仅移出索引)' })),
|
|
188
|
+
}, { additionalProperties: false }),
|
|
189
|
+
async execute(_id, params) {
|
|
190
|
+
const id = String(params.concept_id).replace(/\.md$/, '')
|
|
191
|
+
try {
|
|
192
|
+
const r: ForgetResult = await forgetCore(root, id, params.delete_file === true)
|
|
193
|
+
await refreshDigest()
|
|
194
|
+
const msg = r.status === 'forgotten' ? `已撤回记忆 ${r.conceptId}${r.reason ? `(${r.reason})` : ''}`
|
|
195
|
+
: r.status === 'not_found' ? `记忆 ${r.conceptId} 不存在或已撤回`
|
|
196
|
+
: `撤回失败:${r.reason || r.status}`
|
|
197
|
+
return text(msg, r as unknown as Record<string, unknown>)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return text(`撤回失败:${String((e as Error).message || e)}`, { status: 'error', reason: String((e as Error).message || e) })
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
pi.registerTool({
|
|
205
|
+
name: 'okf_graph',
|
|
206
|
+
label: '记忆图谱',
|
|
207
|
+
description:
|
|
208
|
+
'导出记忆库的图谱 JSON:nodes(概念:title/type/tags/weight/state)+edges(交叉链接)+timeline(权重快照)。',
|
|
209
|
+
promptSnippet: '导出记忆图谱 JSON(nodes/edges/timeline)',
|
|
210
|
+
parameters: Type.Object({
|
|
211
|
+
limit: Type.Optional(Type.Number({ description: '可选:节点上限,默认全部' })),
|
|
212
|
+
}, { additionalProperties: false }),
|
|
213
|
+
async execute(_id, params) {
|
|
214
|
+
const g = await buildGraph(root)
|
|
215
|
+
const nodes = params.limit && Number(params.limit) > 0
|
|
216
|
+
? g.nodes.slice(0, Math.min(Number(params.limit), 500))
|
|
217
|
+
: g.nodes
|
|
218
|
+
return text(
|
|
219
|
+
`记忆图谱:${nodes.length} 节点 · ${g.edges.length} 边 · ${g.timeline.length} 权重记录`,
|
|
220
|
+
{ meta: g.meta, nodes, edges: g.edges, timeline: g.timeline },
|
|
221
|
+
)
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// ── 修复 dsh 侧的反馈回路缺口:recordSelect / recordSkip 此前无任何调用入口 ──
|
|
226
|
+
pi.registerTool({
|
|
227
|
+
name: 'okf_feedback',
|
|
228
|
+
label: '记忆反馈',
|
|
229
|
+
description:
|
|
230
|
+
'对某条记忆给出显式反馈,更新其学习权重。action=select(用户选中/确认采用)权重 +1.0;' +
|
|
231
|
+
'action=skip(用户跳过/否定)权重 −0.5。用于技术选型候选拍板、被否定结论等场景。',
|
|
232
|
+
promptSnippet: '记录用户对某条记忆的选中(+1.0)或跳过(−0.5)反馈',
|
|
233
|
+
promptGuidelines: [
|
|
234
|
+
'Use okf_feedback with action "select" when the user confirms or picks a memory-backed option (for example a TechChoice candidate), and with action "skip" when the user rejects or ignores one.',
|
|
235
|
+
],
|
|
236
|
+
parameters: Type.Object({
|
|
237
|
+
concept_id: Type.String({ description: '概念 ID' }),
|
|
238
|
+
action: Type.String({ description: 'select(选中,+1.0)或 skip(跳过,−0.5)' }),
|
|
239
|
+
}, { additionalProperties: false }),
|
|
240
|
+
async execute(_id, params) {
|
|
241
|
+
const id = String(params.concept_id).replace(/\.md$/, '')
|
|
242
|
+
const action = String(params.action)
|
|
243
|
+
if (action !== 'select' && action !== 'skip') {
|
|
244
|
+
return text(`无效 action:「${action}」;可选 select / skip`, { status: 'error' })
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const weight = action === 'select'
|
|
248
|
+
? await recordSelect(root, id)
|
|
249
|
+
: await recordSkip(root, id)
|
|
250
|
+
return text(`${action === 'select' ? '已记录选中' : '已记录跳过'} ${id} → 权重 ${weight.toFixed(2)}`, { status: 'ok', conceptId: id, action, weight })
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return text(`反馈记录失败:${String((e as Error).message || e)}`, { status: 'error', reason: String((e as Error).message || e) })
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ──────────────────── 系统提示注入(替代 dsh 的 section API) ────────────────────
|
|
258
|
+
|
|
259
|
+
pi.on('before_agent_start', async (event) => {
|
|
260
|
+
return {
|
|
261
|
+
systemPrompt: `${event.systemPrompt}\n\n${MEMORY_DISCIPLINE}\n\n# 记忆库现状\n\n${digest}\n\n${RECALL_GUIDE}`,
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ────────────────────────────── 命令 ──────────────────────────────
|
|
266
|
+
// 单一入口 /okf + 子命令 — 比 /memory-search、/memory-consolidate 这类长名少敲很多,
|
|
267
|
+
// 且子命令可 Tab 补全。pi 生态的常见形态(如 /subagents-guide [topic]、/run <agent> [task])。
|
|
268
|
+
|
|
269
|
+
/** 子命令表(同时用于参数补全与用法提示) */
|
|
270
|
+
const SUBS: Array<{ value: string; hint: string }> = [
|
|
271
|
+
{ value: 'status', hint: '库状态 / 概念数 / 权重榜(默认)' },
|
|
272
|
+
{ value: 'search', hint: '搜索记忆:search <关键词>' },
|
|
273
|
+
{ value: 'graph', hint: '导出单文件自包含图谱 HTML 并打开' },
|
|
274
|
+
{ value: 'consolidate', hint: '立即跑一次巩固(衰减 + 归档)' },
|
|
275
|
+
]
|
|
276
|
+
const USAGE = '用法:/okf [status | search <关键词> | graph | consolidate]'
|
|
277
|
+
|
|
278
|
+
/** /okf(不带参数)= status */
|
|
279
|
+
const cmdStatus = async (ctx: ExtensionContext): Promise<void> => {
|
|
280
|
+
const concepts = await scanBundle(root)
|
|
281
|
+
const meta = await loadMeta(root)
|
|
282
|
+
const entries = Object.entries(meta.entries)
|
|
283
|
+
.sort((a, b) => b[1].weight - a[1].weight)
|
|
284
|
+
.slice(0, 8)
|
|
285
|
+
const lines = [
|
|
286
|
+
`记忆库:${root}`,
|
|
287
|
+
`概念数:${concepts.length}`,
|
|
288
|
+
entries.length ? `\n权重榜(前 ${entries.length}):` : '',
|
|
289
|
+
...entries.map(([id, e]) => ` ${e.weight.toFixed(2)} ${e.state === 'inactive' ? '⏸ ' : ''}${id} (访问 ${e.accessCount} 次)`),
|
|
290
|
+
].filter(Boolean)
|
|
291
|
+
ctx.ui.notify(lines.join('\n'), 'info')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const cmdSearch = async (q: string, ctx: ExtensionContext): Promise<void> => {
|
|
295
|
+
const query = q.trim()
|
|
296
|
+
if (!query) {
|
|
297
|
+
ctx.ui.notify('用法:/okf search <关键词>', 'warning')
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
const hits = await preload(root, query, { limit: 8 })
|
|
301
|
+
if (hits.length === 0) {
|
|
302
|
+
ctx.ui.notify(`记忆库无匹配:「${query}」`, 'info')
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
ctx.ui.notify(
|
|
306
|
+
hits.map((h) => `${h.weight.toFixed(2)} ${h.conceptId} (${h.type}) — ${h.description}`).join('\n'),
|
|
307
|
+
'info',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const cmdGraph = async (ctx: ExtensionContext): Promise<void> => {
|
|
312
|
+
const g = await buildGraph(root)
|
|
313
|
+
const out = path.join(os.tmpdir(), `okf-memory-graph-${Date.now()}.html`)
|
|
314
|
+
await fs.writeFile(out, renderGraphHtml(g), 'utf8')
|
|
315
|
+
try {
|
|
316
|
+
await pi.exec('open', [out])
|
|
317
|
+
ctx.ui.notify(`已打开记忆图谱(${g.nodes.length} 节点 / ${g.edges.length} 边)\n${out}`, 'info')
|
|
318
|
+
} catch (e) {
|
|
319
|
+
ctx.ui.notify(`图谱已生成(自动打开失败:${String((e as Error).message || e)}):\n${out}`, 'warning')
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const cmdConsolidate = async (ctx: ExtensionContext): Promise<void> => {
|
|
324
|
+
const meta = await consolidate(root)
|
|
325
|
+
const entries = Object.values(meta.entries)
|
|
326
|
+
const inactive = entries.filter((e) => e.state === 'inactive').length
|
|
327
|
+
ctx.ui.notify(`巩固完成:${entries.length} 条记忆,其中 ${inactive} 条已归档(inactive)`, 'info')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
pi.registerCommand('okf', {
|
|
331
|
+
description: 'OKF 记忆库:/okf [status|search <词>|graph|consolidate]',
|
|
332
|
+
getArgumentCompletions: (prefix: string) => {
|
|
333
|
+
// 子命令后再跟参数(如 search 之后的关键词)就不再补全
|
|
334
|
+
if (prefix.includes(' ')) return null
|
|
335
|
+
const p = prefix.trim()
|
|
336
|
+
return SUBS.filter((s) => s.value.startsWith(p)).map((s) => ({ value: s.value, label: `${s.value} — ${s.hint}` }))
|
|
337
|
+
},
|
|
338
|
+
handler: async (args, ctx: ExtensionContext) => {
|
|
339
|
+
const raw = String(args || '').trim()
|
|
340
|
+
const sp = raw.indexOf(' ')
|
|
341
|
+
const sub = (sp === -1 ? raw : raw.slice(0, sp)).toLowerCase()
|
|
342
|
+
const rest = sp === -1 ? '' : raw.slice(sp + 1).trim()
|
|
343
|
+
switch (sub) {
|
|
344
|
+
case '':
|
|
345
|
+
case 'status':
|
|
346
|
+
await cmdStatus(ctx)
|
|
347
|
+
return
|
|
348
|
+
case 'search':
|
|
349
|
+
case 's':
|
|
350
|
+
await cmdSearch(rest, ctx)
|
|
351
|
+
return
|
|
352
|
+
case 'graph':
|
|
353
|
+
case 'g':
|
|
354
|
+
await cmdGraph(ctx)
|
|
355
|
+
return
|
|
356
|
+
case 'consolidate':
|
|
357
|
+
case 'c':
|
|
358
|
+
await cmdConsolidate(ctx)
|
|
359
|
+
return
|
|
360
|
+
case 'help':
|
|
361
|
+
case '?':
|
|
362
|
+
ctx.ui.notify(`${USAGE}\n\n${SUBS.map((s) => ` ${s.value.padEnd(12)} ${s.hint}`).join('\n')}`, 'info')
|
|
363
|
+
return
|
|
364
|
+
default:
|
|
365
|
+
ctx.ui.notify(`未知子命令「${sub}」\n${USAGE}`, 'warning')
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// ─────────────────────────── 生命周期 ───────────────────────────
|
|
371
|
+
|
|
372
|
+
pi.on('session_start', async () => {
|
|
373
|
+
await ensureRoot(root)
|
|
374
|
+
await refreshDigest()
|
|
375
|
+
// 巩固定时器:用 globalThis 标记保证 /reload 后不会叠加多个定时器
|
|
376
|
+
// (consolidate 是按天数连续衰减,重复运行会加速遗忘)
|
|
377
|
+
const g = globalThis as Record<string, unknown>
|
|
378
|
+
if (!g.__okfConsolidationStarted) {
|
|
379
|
+
g.__okfConsolidationStarted = true
|
|
380
|
+
startConsolidation(root)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
}
|