@akasha-book/session 0.2.4 → 0.3.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 +14 -11
- package/lib/index-worker.mjs +72 -0
- package/lib/index.js +157 -15
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
# @akasha-book/session(小阿卡夏)
|
|
1
|
+
# @akasha-book/session(小阿卡夏)
|
|
2
|
+
|
|
3
|
+
阿卡夏之书**会话层**插件 —— **自动增量索引 + 压缩事件行 + 节奏条 + 循环观测 P0(dry 干跑)**。
|
|
4
|
+
核心逻辑归 `core/session.mjs` 与 `core/loop-detect.mjs`(本插件只做调度与注入;降级只留痕,不炸宿主)。
|
|
5
|
+
|
|
6
|
+
- **触发**:`session/event → turn/end`(按会话去抖 `minIndexIntervalMs`,默认 30s)→ 增量索引(水位 + 指纹双幂等);`agent/status → idle` 追平;`ctx.interval` 兜底。
|
|
7
|
+
- **压缩事件行**:`session/event → compaction/end`(成功压缩)→ 先强制补索引(绕过去抖/背压)→ 观测线(带 `indexed`/`reason`)+ 节奏条点名。**只有补索引确认成功才说「已收入」**;失败时改说「可能未进会话层」并给回退(原始会话档案 / `session index`),同会话后续索引成功即改口(wave1 · B1)。
|
|
8
|
+
- **循环观测 P0**:**dry 干跑——只观测、只写观测线;绝不干预**。流环=`agent/assistant-stream` 帧按 attemptId 累积(每 480 字符对尾 8K 跑 `detectLoops`);turn 环=事件流缓存全文兜底;命中与帧类型探针写 `logs/hooks.jsonl`。汇总 CLI:`node akasha.mjs session loopwatch`。
|
|
9
|
+
- **节奏条**:`systemPrompt.context`(`akasha:session`,order 134)——段数 / 会话数 + 回看提示;无数据静默。
|
|
10
|
+
- **索引观测线**:`session-index`(added / skipped / parseFails / ms / **lagMs**=事件循环延迟采样)/ `session-index-skip` / `session-index-error` / **`session-index-slow`**(单次索引 >1.5s 告警,含 lagMs)。
|
|
11
|
+
- 配置:`akashaDir`(核心库位置)/ `sessionsRoot`(宿主会话档案目录)/ `storeFile` / `metaFile` / `log`;默认见 `lib/index.js` 的 DEFAULTS。
|
|
12
|
+
- 自测:`node selftest.mjs`(桩 ctx 端到端;临时目录,不碰真实数据;需与本包 `core/` 并列)。
|
|
2
13
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
- **触发**:`session/event → turn/end`(按会话去抖 `minIndexIntervalMs`,默认 30s)→ 增量索引(水位 + 指纹双幂等);`agent/status → idle` 追平;`ctx.interval` 兜底。
|
|
7
|
-
- **压缩事件行**:`session/event → compaction/end`(成功压缩)→ 观测线 + 节奏条点名(被折叠历史的细节优先查会话层)。
|
|
8
|
-
- **循环观测 P0**:**dry 干跑——只观测、只写观测线;绝不干预**。流环=`agent/assistant-stream` 帧按 attemptId 累积(每 480 字符对尾 8K 跑 `detectLoops`);turn 环=事件流缓存全文兜底;命中与帧类型探针写 `logs/hooks.jsonl`。汇总 CLI:`node akasha.mjs session loopwatch`。
|
|
9
|
-
- **节奏条**:`systemPrompt.context`(`akasha:session`,order 134)——段数 / 会话数 + 回看提示;无数据静默。
|
|
10
|
-
- **索引观测线**:`session-index`(added / skipped / parseFails / ms / **lagMs**=事件循环延迟采样)/ `session-index-skip` / `session-index-error` / **`session-index-slow`**(单次索引 >1.5s 告警,含 lagMs)。
|
|
11
|
-
- 配置:`akashaDir`(核心库位置)/ `sessionsRoot`(宿主会话档案目录)/ `storeFile` / `metaFile` / `log`;默认见 `lib/index.js` 的 DEFAULTS。
|
|
12
|
-
- 自测:`node selftest.mjs`(桩 ctx 端到端;临时目录,不碰真实数据;需与本包 `core/` 并列)。
|
|
14
|
+
- **索引 worker 化(2026-10-10 · 计划队列 v0.4 项)**:宿主回调只投递(`postMessage`,µs 级返回),索引在**常驻 worker 线程**(`lib/index-worker.mjs`,插件加载即预热)串行执行、同会话排队去重;`indexSession` 以 `metaFile` 为锁对象串行化整段「读水位 → 追加 → 记账」(`core/lib.mjs` 的 `withFileLock`;**锁 meta 而非 store**——`appendSegments` 内部已持 store 锁,嵌套会死等)。退出协议:dispose → `{"type":"quit"}` → drain → 关闭(5s 兜底强杀)。降级链:`indexMode: 'worker' | 'inline'`(默认 worker;**连续** 3 次失败自动降级,任何成功回零)。
|
|
15
|
+
- **观测语义(worker 化后)**:`session-index` 新增 `via`(worker/inline)与 `hostMs`(宿主投递耗时);**`session-index-slow` 的含义变为「后台耗时 >1.5s」**——宿主卡顿请看 `hostMs` / `lagMs`。
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// index-worker.mjs —— 小阿卡夏 v0.4 索引 worker(worker_threads 入口)。
|
|
2
|
+
// 协议(宿主 → worker):{type:'index', id, job:{akashaDir,session,file,storeFile,metaFile,trigger,full}}
|
|
3
|
+
// {type:'quit'} / {type:'ping'}
|
|
4
|
+
// 协议(worker → 宿主):{type:'ready'} / {type:'pong'} / {type:'result', id, session, trigger, ms, ...stats|error}
|
|
5
|
+
// 语义:FIFO 串行;同 session 排队去重(保留最新一条);quit 时 drain 当前 job 后关闭。
|
|
6
|
+
import { parentPort } from 'node:worker_threads';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const require_ = createRequire(import.meta.url);
|
|
11
|
+
let core = null;
|
|
12
|
+
let coreDir = '';
|
|
13
|
+
function coreOf(akashaDir) {
|
|
14
|
+
if (!core || coreDir !== akashaDir) {
|
|
15
|
+
core = require_(join(akashaDir, 'session.mjs'));
|
|
16
|
+
coreDir = akashaDir;
|
|
17
|
+
}
|
|
18
|
+
return core;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const queue = [];
|
|
22
|
+
const queuedBySession = new Set();
|
|
23
|
+
let running = false;
|
|
24
|
+
let quitting = false;
|
|
25
|
+
let processed = 0;
|
|
26
|
+
|
|
27
|
+
function enqueue(job) {
|
|
28
|
+
if (queuedBySession.has(job.session)) {
|
|
29
|
+
// 同会话已在排队:保留最新一份(旧 job 丢弃;水位/去重保证不漏段)
|
|
30
|
+
for (let i = queue.length - 1; i >= 0; i--) if (queue[i].session === job.session) { queue.splice(i, 1); break; }
|
|
31
|
+
queuedBySession.delete(job.session);
|
|
32
|
+
}
|
|
33
|
+
queue.push(job);
|
|
34
|
+
queuedBySession.add(job.session);
|
|
35
|
+
parentPort.postMessage({ type: 'queued', id: job.id, session: job.session, depth: queue.length, running });
|
|
36
|
+
if (!running) drain();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function drain() {
|
|
40
|
+
running = true;
|
|
41
|
+
const job = queue.shift();
|
|
42
|
+
if (!job) {
|
|
43
|
+
running = false;
|
|
44
|
+
if (quitting) { parentPort.postMessage({ type: 'bye', processed }); parentPort.close(); }
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
queuedBySession.delete(job.session);
|
|
48
|
+
const t0 = Date.now();
|
|
49
|
+
try {
|
|
50
|
+
const stats = coreOf(job.akashaDir).indexSession({
|
|
51
|
+
file: job.file, session: job.session,
|
|
52
|
+
storeFile: job.storeFile, metaFile: job.metaFile, full: !!job.full,
|
|
53
|
+
});
|
|
54
|
+
processed += 1;
|
|
55
|
+
parentPort.postMessage({ type: 'result', id: job.id, session: job.session, trigger: job.trigger, ms: Date.now() - t0, depth: queue.length, ...stats });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
parentPort.postMessage({ type: 'result', id: job.id, session: job.session, trigger: job.trigger, ms: Date.now() - t0, depth: queue.length, error: String(error?.message ?? error).slice(0, 300) });
|
|
58
|
+
}
|
|
59
|
+
setImmediate(drain);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
parentPort.on('message', (msg) => {
|
|
63
|
+
if (!msg || typeof msg !== 'object') return;
|
|
64
|
+
if (msg.type === 'index' && msg.job?.session) enqueue({ ...msg.job, id: msg.id });
|
|
65
|
+
else if (msg.type === 'ping') parentPort.postMessage({ type: 'pong', depth: queue.length, running });
|
|
66
|
+
else if (msg.type === 'quit') {
|
|
67
|
+
quitting = true;
|
|
68
|
+
if (!running && queue.length === 0) { parentPort.postMessage({ type: 'bye', processed }); parentPort.close(); }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
parentPort.postMessage({ type: 'ready', pid: process.pid, node: process.version });
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 阿卡夏之书会话层插件 v0.2(@akasha-book/
|
|
1
|
+
// 阿卡夏之书会话层插件 v0.2(@akasha-book/session)—— 自动增量索引 + 压缩事件行 + 节奏条 + 循环观测 P0(dry)。
|
|
2
2
|
// · 核心逻辑归 ~/.akasha/session.mjs(插件只做调度与注入;降级只留痕,不炸宿主)
|
|
3
3
|
// · 观测线(hooks.jsonl):session-armed / session-index / session-index-skip / session-index-error / session-compact
|
|
4
4
|
// + 循环观测(P0 干跑,2026-10-07):loop-watch / loop-watch-probe —— **只观测、绝不干预**(不 steer / 不 cancel / 不注入)
|
|
@@ -8,6 +8,7 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
+
import { Worker } from 'node:worker_threads';
|
|
11
12
|
|
|
12
13
|
export const inject = ['systemPrompt', 'timer'];
|
|
13
14
|
|
|
@@ -24,7 +25,9 @@ const DEFAULTS = {
|
|
|
24
25
|
contextOrder: 134,
|
|
25
26
|
loopWatch: true,
|
|
26
27
|
watchMinChars: 200,
|
|
27
|
-
watchCheckEvery: 480
|
|
28
|
+
watchCheckEvery: 480,
|
|
29
|
+
indexMode: 'worker', // 'worker'(v0.4 默认)| 'inline'(回退)
|
|
30
|
+
workerRespawnMs: 15000
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/** 去抖判定(纯函数)。 */
|
|
@@ -37,7 +40,19 @@ export function effectiveThrottle(size, baseMs) {
|
|
|
37
40
|
return size > 16 * 1024 * 1024 ? baseMs * 4 : baseMs;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/**
|
|
43
|
+
/** 压缩后补索引失败时的原因短语(给节奏条用;未知原因如实写「未确认」)。 */
|
|
44
|
+
const COMPACT_FAIL_REASON = {
|
|
45
|
+
'not-found': '未找到会话档案',
|
|
46
|
+
backoff: '近期索引失败、处于背压窗口',
|
|
47
|
+
error: '索引出错',
|
|
48
|
+
'no-session': '事件缺会话 id'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 节奏条渲染(纯函数):段数/会话数 + 回看提示;压缩窗口内追一行。无数据 → null(静默)。
|
|
53
|
+
* B1(2026-10 wave1):只有「最近一次压缩的补索引确认成功」(compacts[i].indexed === true)才可说「已收入」;
|
|
54
|
+
* 失败 / 未确认(含旧格式条目无 indexed 字段)一律改说「可能未进会话层」并给回退路径——提示行不得替索引作伪证。
|
|
55
|
+
*/
|
|
41
56
|
export function renderSessionLine(state, now) {
|
|
42
57
|
const sessions = state?.meta?.sessions ?? {};
|
|
43
58
|
const ids = Object.keys(sessions);
|
|
@@ -51,7 +66,13 @@ export function renderSessionLine(state, now) {
|
|
|
51
66
|
const last = compacts[compacts.length - 1];
|
|
52
67
|
const s = state?.meta?.sessions?.[last?.session];
|
|
53
68
|
const counts = s ? `(该会话已索引 ${Number(s.segments ?? 0)} 段 · 结论 ${Number(s.conclusions ?? 0)} 条)` : '';
|
|
54
|
-
|
|
69
|
+
if (last?.indexed === true) {
|
|
70
|
+
lines.push(`⚠ 近期发生压缩(${compacts.length} 次)——本段历史已收入会话层${counts};细节查 akasha_session_lookup`);
|
|
71
|
+
} else {
|
|
72
|
+
const why = COMPACT_FAIL_REASON[last?.reason] ?? '补索引未确认';
|
|
73
|
+
lines.push(`⚠ 近期发生压缩(${compacts.length} 次),但压缩后补索引未成功(${why})——这段历史可能未进会话层;` +
|
|
74
|
+
`勿凭会话层断言「查无此事」,回看请查原始会话档案,或手动 node akasha.mjs session index <会话id>`);
|
|
75
|
+
}
|
|
55
76
|
}
|
|
56
77
|
return lines.join('\n');
|
|
57
78
|
}
|
|
@@ -223,20 +244,118 @@ export function apply(ctx, config = {}) {
|
|
|
223
244
|
}
|
|
224
245
|
};
|
|
225
246
|
|
|
226
|
-
|
|
247
|
+
// 某会话索引成功 → 该会话窗口内「补索引未确认」的压缩条目改记为已收入(档案是全量的,后补成功即真收入)。
|
|
248
|
+
const settleCompacts = (sid, trigger) => {
|
|
249
|
+
for (const c of compacts) if (c.session === sid && c.indexed !== true) { c.indexed = true; c.reason = 'recovered'; c.recoveredBy = trigger; }
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// ---------- 索引 worker(v0.4):宿主只投递任务,重活在后台线程串行跑 ----------
|
|
253
|
+
// 协议见 lib/index-worker.mjs;降级链:worker → (连续失败或 spawn 失败)→ inline 旧路径。
|
|
254
|
+
const workerUrl = new URL('./index-worker.mjs', import.meta.url);
|
|
255
|
+
let worker = null;
|
|
256
|
+
let workerFails = 0;
|
|
257
|
+
let workerMode = cfg.indexMode === 'inline' ? 'inline' : 'worker';
|
|
258
|
+
let quitting = false;
|
|
259
|
+
let respawnTimer = null;
|
|
260
|
+
let jobSeq = 0;
|
|
261
|
+
const pendingJobs = new Map(); // id -> { sid, trigger, extra, hostMs, stat }
|
|
262
|
+
|
|
263
|
+
const queueDepth = () => pendingJobs.size;
|
|
264
|
+
|
|
265
|
+
const scheduleRespawn = () => {
|
|
266
|
+
if (respawnTimer || quitting || workerMode !== 'worker') return;
|
|
267
|
+
respawnTimer = setTimeout(() => { respawnTimer = null; spawnWorker(); }, cfg.workerRespawnMs);
|
|
268
|
+
if (typeof respawnTimer.unref === 'function') respawnTimer.unref();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onWorkerMessage = (msg) => {
|
|
272
|
+
if (!msg || typeof msg !== 'object') return;
|
|
273
|
+
if (msg.type === 'ready') { log({ kind: 'session-worker', event: 'ready', node: msg.node }); return; }
|
|
274
|
+
if (msg.type === 'queued') { log({ kind: 'session-worker', event: 'queued', session: msg.session, depth: msg.depth, running: msg.running }); return; }
|
|
275
|
+
if (msg.type === 'bye') { log({ kind: 'session-worker', event: 'bye', processed: msg.processed }); return; }
|
|
276
|
+
if (msg.type !== 'result') return;
|
|
277
|
+
const p = pendingJobs.get(msg.id);
|
|
278
|
+
pendingJobs.delete(msg.id);
|
|
279
|
+
const extra = p?.extra ?? {};
|
|
280
|
+
if (msg.error) {
|
|
281
|
+
lastFailAt.set(msg.session, Date.now());
|
|
282
|
+
log({ kind: 'session-index-error', session: msg.session, trigger: msg.trigger, message: msg.error, workerMs: msg.ms, hostMs: p?.hostMs ?? null, via: 'worker' });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (p?.stat) lastStat.set(msg.session, { size: p.stat.size, mtimeMs: p.stat.mtimeMs }); // 只记成功:失败后同档可重试
|
|
286
|
+
workerFails = 0; // 「连续失败」语义:任何一次成功都回零(2026-10-10 复查修正)
|
|
287
|
+
log({ kind: 'session-index', session: msg.session, trigger: msg.trigger, turn: extra.turn ?? null, added: msg.added, skipped: msg.skipped, parseFails: msg.parseFails, frameFails: msg.frameFails ?? 0, ms: msg.ms, hostMs: p?.hostMs ?? null, lagMs: loopLagMs, via: 'worker' });
|
|
288
|
+
if (msg.ms > 1500) log({ kind: 'session-index-slow', session: msg.session, trigger: msg.trigger, ms: msg.ms, lagMs: loopLagMs, added: msg.added, via: 'worker' });
|
|
289
|
+
settleCompacts(msg.session, msg.trigger);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function spawnWorker() {
|
|
293
|
+
if (workerMode !== 'worker' || worker || quitting) return;
|
|
294
|
+
try {
|
|
295
|
+
worker = new Worker(workerUrl);
|
|
296
|
+
worker.unref?.();
|
|
297
|
+
log({ kind: 'session-worker', event: 'spawn', pid: process.pid, pending: queueDepth() });
|
|
298
|
+
worker.on('message', (msg) => { try { onWorkerMessage(msg); } catch { /* 观测失败不拖宿主 */ } });
|
|
299
|
+
worker.on('error', (err) => {
|
|
300
|
+
log({ kind: 'session-worker-error', event: 'error', message: String(err?.message ?? err).slice(0, 300) });
|
|
301
|
+
worker = null;
|
|
302
|
+
workerFails += 1;
|
|
303
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
304
|
+
else scheduleRespawn();
|
|
305
|
+
});
|
|
306
|
+
worker.on('exit', (code) => {
|
|
307
|
+
const lost = pendingJobs.size;
|
|
308
|
+
if (lost) pendingJobs.clear();
|
|
309
|
+
log({ kind: 'session-worker', event: 'exit', code, lost, quitting });
|
|
310
|
+
worker = null;
|
|
311
|
+
if (!quitting) scheduleRespawn();
|
|
312
|
+
});
|
|
313
|
+
} catch (e) {
|
|
314
|
+
worker = null;
|
|
315
|
+
log({ kind: 'session-worker-error', event: 'spawn', message: String(e?.message ?? e).slice(0, 300) });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
spawnWorker(); // 预热:apply 即起线程,首单不付冷启动
|
|
319
|
+
|
|
320
|
+
/** 投递一个索引任务;返回 { queued:true, hostMs } 或 { queued:false }(走 inline 回退)。 */
|
|
321
|
+
const dispatchIndex = (sid, file, trigger, extra, opts, st) => {
|
|
322
|
+
if (workerMode !== 'worker') return { queued: false };
|
|
323
|
+
spawnWorker();
|
|
324
|
+
if (!worker) return { queued: false };
|
|
325
|
+
const id = ++jobSeq;
|
|
326
|
+
const t0 = Date.now();
|
|
327
|
+
try {
|
|
328
|
+
worker.postMessage({ type: 'index', id, job: { akashaDir: cfg.akashaDir, session: sid, file, storeFile: cfg.storeFile, metaFile: cfg.metaFile, trigger, full: !!opts.full } });
|
|
329
|
+
const hostMs = Date.now() - t0;
|
|
330
|
+
pendingJobs.set(id, { sid, trigger, extra, hostMs, stat: st });
|
|
331
|
+
return { queued: true, hostMs };
|
|
332
|
+
} catch (e) {
|
|
333
|
+
workerFails += 1;
|
|
334
|
+
log({ kind: 'session-worker-error', event: 'post', message: String(e?.message ?? e).slice(0, 200) });
|
|
335
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
336
|
+
return { queued: false };
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 返回索引结论(B1):{ ok:true, reason:'indexed'|'unchanged' } = 档案内容已在会话层;
|
|
342
|
+
* { ok:false, reason:'no-session'|'backoff'|'not-found'|'error' } = 没收进去;
|
|
343
|
+
* { ok:null, reason:'debounced' } = 去抖跳过(不作结论)。opts.force(压缩路径)绕过去抖与失败背压。
|
|
344
|
+
*/
|
|
345
|
+
const indexNow = (sidIn, trigger, extra = {}, opts = {}) => {
|
|
227
346
|
const sid = String(sidIn ?? '').replace(/^session-/, ''); // 前缀归一:宿主事件常见 'session-<id>',目录 / 记账恒用裸 id
|
|
228
347
|
try {
|
|
229
|
-
if (!sid) return; // 空 id:静默跳过(正常路径不出现;见 README 降级节)
|
|
348
|
+
if (!sid) return { ok: false, reason: 'no-session' }; // 空 id:静默跳过(正常路径不出现;见 README 降级节)
|
|
230
349
|
const now = Date.now();
|
|
231
|
-
if (now - (lastFailAt.get(sid) ?? 0) < FAIL_BACKOFF_MS) return; // 失败背压:窗口内静默
|
|
350
|
+
if (!opts.force && now - (lastFailAt.get(sid) ?? 0) < FAIL_BACKOFF_MS) return { ok: false, reason: 'backoff' }; // 失败背压:窗口内静默
|
|
232
351
|
const file = core().resolveSessionFile(cfg.sessionsRoot, sid);
|
|
233
|
-
if (!file) { lastFailAt.set(sid, now); log({ kind: 'session-index-error', session: sid, trigger, message: '未找到会话档案' }); return; }
|
|
352
|
+
if (!file) { lastFailAt.set(sid, now); log({ kind: 'session-index-error', session: sid, trigger, message: '未找到会话档案' }); return { ok: false, reason: 'not-found' }; }
|
|
234
353
|
lastFailAt.delete(sid);
|
|
235
354
|
let st = null;
|
|
236
355
|
try { st = statSync(file); } catch { /* stat 失败 → 走全量 */ }
|
|
237
|
-
if (!shouldIndex(lastIndexedAt.get(sid) ?? 0, now, effectiveThrottle(st?.size ?? 0, cfg.minIndexIntervalMs))) {
|
|
356
|
+
if (!opts.force && !shouldIndex(lastIndexedAt.get(sid) ?? 0, now, effectiveThrottle(st?.size ?? 0, cfg.minIndexIntervalMs))) {
|
|
238
357
|
log({ kind: 'session-index-skip', session: sid, trigger, sinceMs: now - (lastIndexedAt.get(sid) ?? 0) });
|
|
239
|
-
return;
|
|
358
|
+
return { ok: null, reason: 'debounced' };
|
|
240
359
|
}
|
|
241
360
|
// 设计:去抖标记先置位——索引失败后 30s 内不重试(错误背压);统一失败窗口见 lastFailAt
|
|
242
361
|
lastIndexedAt.set(sid, now);
|
|
@@ -245,20 +364,30 @@ export function apply(ctx, config = {}) {
|
|
|
245
364
|
const prev = lastStat.get(sid);
|
|
246
365
|
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) {
|
|
247
366
|
log({ kind: 'session-index-skip', session: sid, trigger, reason: 'unchanged' });
|
|
248
|
-
|
|
367
|
+
settleCompacts(sid, trigger);
|
|
368
|
+
return { ok: true, reason: 'unchanged' };
|
|
249
369
|
}
|
|
250
370
|
}
|
|
251
371
|
const t0 = Date.now();
|
|
372
|
+
const disp = dispatchIndex(sid, file, trigger, extra, opts, st);
|
|
373
|
+
if (disp.queued) {
|
|
374
|
+
// 宿主只投递(v0.4):结果由 worker 回传,落地线条在 onWorkerMessage
|
|
375
|
+
return { ok: null, reason: 'queued', hostMs: disp.hostMs };
|
|
376
|
+
}
|
|
377
|
+
// 回退:inline 旧路径(indexMode:'inline' / worker 不可用 / 连续失败降级)
|
|
252
378
|
const r = core().indexSession({ file, session: sid, storeFile: cfg.storeFile, metaFile: cfg.metaFile });
|
|
253
379
|
if (st) lastStat.set(sid, { size: st.size, mtimeMs: st.mtimeMs }); // 只记成功:失败后同档可重试
|
|
254
380
|
const dur = Date.now() - t0;
|
|
255
|
-
log({ kind: 'session-index', session: sid, trigger, turn: extra.turn ?? null, added: r.added, skipped: r.skipped, parseFails: r.parseFails, frameFails: r.frameFails ?? 0, ms: dur, lagMs: loopLagMs });
|
|
256
|
-
if (dur > 1500) log({ kind: 'session-index-slow', session: sid, trigger, ms: dur, lagMs: loopLagMs, added: r.added }); // >1.5s 告警线(2026-10-07 补4)
|
|
381
|
+
log({ kind: 'session-index', session: sid, trigger, turn: extra.turn ?? null, added: r.added, skipped: r.skipped, parseFails: r.parseFails, frameFails: r.frameFails ?? 0, ms: dur, lagMs: loopLagMs, via: 'inline' });
|
|
382
|
+
if (dur > 1500) log({ kind: 'session-index-slow', session: sid, trigger, ms: dur, lagMs: loopLagMs, added: r.added, via: 'inline' }); // >1.5s 告警线(2026-10-07 补4)
|
|
383
|
+
settleCompacts(sid, trigger);
|
|
384
|
+
return { ok: true, reason: 'indexed', added: r.added };
|
|
257
385
|
} catch (error) {
|
|
258
386
|
const msg = String(error?.message ?? error);
|
|
259
387
|
const hint = /not yet fully loaded|does not provide an export named|Cannot find module/.test(msg) ? '(疑似宿主模块代缓存——重启桌面端后恢复)' : '';
|
|
260
388
|
lastFailAt.set(sid, Date.now());
|
|
261
389
|
log({ kind: 'session-index-error', session: sid, trigger, message: (msg + hint).slice(0, 300) });
|
|
390
|
+
return { ok: false, reason: 'error' };
|
|
262
391
|
}
|
|
263
392
|
};
|
|
264
393
|
|
|
@@ -268,9 +397,13 @@ export function apply(ctx, config = {}) {
|
|
|
268
397
|
if (event?.type === 'turn/end') indexNow(String(session?.id ?? ''), 'turn-end', { turn: event?.data?.turn ?? null });
|
|
269
398
|
else if (event?.type === 'compaction/end' && event?.data?.error === void 0) {
|
|
270
399
|
const sid = String(session?.id ?? '').replace(/^session-/, '');
|
|
271
|
-
|
|
400
|
+
// 先索引再宣称「已收入」(2026-10 复查);B1(wave1):宣称与否取决于索引结论——
|
|
401
|
+
// 压缩是稀有且关键的时刻,force 绕过去抖/背压强制补一次;失败如实记 indexed:false,节奏条不说「已收入」。
|
|
402
|
+
const res = indexNow(sid, 'compaction', {}, { force: true }) ?? { ok: false, reason: 'error' };
|
|
403
|
+
const indexed = res.ok === true;
|
|
404
|
+
compacts.push({ session: sid, ts: Date.now(), indexed, reason: res.reason });
|
|
272
405
|
if (compacts.length > 20) compacts.splice(0, compacts.length - 20);
|
|
273
|
-
log({ kind: 'session-compact', session: sid });
|
|
406
|
+
log({ kind: 'session-compact', session: sid, indexedFirst: true, indexed, reason: res.reason });
|
|
274
407
|
}
|
|
275
408
|
} catch { /* 静默 */ }
|
|
276
409
|
});
|
|
@@ -300,6 +433,15 @@ export function apply(ctx, config = {}) {
|
|
|
300
433
|
} catch { /* 静默 */ }
|
|
301
434
|
}
|
|
302
435
|
|
|
436
|
+
// 退出协议(v0.4):通知 worker drain 后关闭;5s 未退则强杀。
|
|
437
|
+
ctx.effect(() => () => {
|
|
438
|
+
quitting = true;
|
|
439
|
+
try { worker?.postMessage({ type: 'quit' }); } catch { /* ignore */ }
|
|
440
|
+
const w = worker;
|
|
441
|
+
const t = setTimeout(() => { try { w?.terminate(); } catch { /* ignore */ } }, 5000);
|
|
442
|
+
if (typeof t.unref === 'function') t.unref();
|
|
443
|
+
});
|
|
444
|
+
|
|
303
445
|
// 条子:每回合一行(无内容静默)。
|
|
304
446
|
ctx.systemPrompt.context({
|
|
305
447
|
name: 'akasha:session',
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akasha-book/session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"engines": { "node": ">=22.15.0" },
|
|
4
5
|
"license": "MIT",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"description": "小阿卡夏:会话层插件 —— 自动增量索引(turn/end 去抖)+ 压缩事件行 + 每回合节奏条 + 循环观测 P0(dry 干跑,只观测不干预)",
|
|
@@ -9,6 +10,7 @@
|
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"lib/index.js",
|
|
13
|
+
"lib/index-worker.mjs",
|
|
12
14
|
"cordis.patch.yml",
|
|
13
15
|
"README.md"
|
|
14
16
|
],
|