@akasha-book/session 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/lib/index-worker.mjs +72 -0
- package/lib/index.js +112 -4
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -10,3 +10,32 @@
|
|
|
10
10
|
- **索引观测线**:`session-index`(added / skipped / parseFails / ms / **lagMs**=事件循环延迟采样)/ `session-index-skip` / `session-index-error` / **`session-index-slow`**(单次索引 >1.5s 告警,含 lagMs)。
|
|
11
11
|
- 配置:`akashaDir`(核心库位置)/ `sessionsRoot`(宿主会话档案目录)/ `storeFile` / `metaFile` / `log`;默认见 `lib/index.js` 的 DEFAULTS。
|
|
12
12
|
- 自测:`node selftest.mjs`(桩 ctx 端到端;临时目录,不碰真实数据;需与本包 `core/` 并列)。
|
|
13
|
+
|
|
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`。
|
|
16
|
+
|
|
17
|
+
<!-- store-status -->
|
|
18
|
+
## 兼容与状态(DSH STORE 口径 · 2026-10-10)
|
|
19
|
+
|
|
20
|
+
**固定源(immutable source)**:本版本的源码冻结于 Commit `d311ad54d1dc2cd2697c7b9eeac57eaa6466fc04`(对应 `plugins/akasha-session/package.json` 的 version)。
|
|
21
|
+
兼容声明见 manifest 的 `dsh.compatibility.dshReleases`:**0.2.0-rc.2 = compatible**(本机实测运行),
|
|
22
|
+
`0.2.1-alpha.1` / `0.2.1-alpha.2` 尚未实测 ⇒ 按契约如实写 `unknown`(不猜、不吹)。
|
|
23
|
+
|
|
24
|
+
| 证据层 | 状态 | 依据 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 静态契约(manifest / Bundle Patch / 许可证 / 入口 ID) | **verified** | 官方 `build-dsh-plugin` 审计:静态分见本包审计输出;入口 ID 为插件自有,不 disable/replace 任何 `@deepseek-ai/*` |
|
|
27
|
+
| 单元与边界测试(`npm test`) | **verified** | `node selftest.test.mjs` 全绿;覆盖 malformed 输入、并发/节流、replay 一致性(见该文件断言) |
|
|
28
|
+
| 一次性 Profile 安装·启动·卸载(E3) | **verified** | 2026-10-10 一次性 DSH_HOME 实跑:install → cold start(HTTP 就绪)→ stop → uninstall → `--dump-config` 逐字回到基线;dsh 0.2.0-rc.2;证据见同目录 `EVIDENCE.json` |
|
|
29
|
+
| 真实 Profile 运行 | **verified(本机)** | 桌面端 0.2.0-rc.2 实跑;**他人机器 unverified**(未做外部验收) |
|
|
30
|
+
| 独立安全审计 / 公开分发(E5) | **unverified** | 未做独立审计;分发前应重评 |
|
|
31
|
+
|
|
32
|
+
**下一道门(next gate)**:① 在 0.2.1-alpha.x 上按同一套用例复测(一次性 DSH_HOME),把 `unknown` 改为精确结论(`compatible` 或 `incompatible`);
|
|
33
|
+
② 提交 DSH STORE 上架申请(monorepo 子路径:`tree/main/plugins/<name>`)并跟进机器人预检。
|
|
34
|
+
若任一版本复测失败,该版本标注为 `blocked`(不兼容)并保持其余版本声明不动。
|
|
35
|
+
|
|
36
|
+
**权限 / 非目标 / 边界(permissions · non-goals · boundaries)**
|
|
37
|
+
- 读取:会话档案目录(默认 `~/.dsh/sessions`,可配置);写入:只向 `data/session.jsonl` / `session-meta.json`(本插件自有索引)追加段记录;
|
|
38
|
+
索引在 **worker 线程**内跑(`lib/index-worker.mjs`),带单飞与最小间隔节流;**不修改会话档案原文**。
|
|
39
|
+
- **统一非目标(non-goals)**:不修改 DSH 核心与官方包;不替换、禁用或遮蔽任何官方组件;不写真实 Profile(测试一律用系统临时目录);
|
|
40
|
+
不在日志/输出里暴露凭据、完整用户文件或注入上下文。
|
|
41
|
+
- **测试隔离**:所有自测只使用 `os.tmpdir()` 下的临时目录;不读写真实库(`~/.akasha`)与真实会话档案。
|
|
@@ -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
|
@@ -8,11 +8,14 @@ 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
|
|
|
14
15
|
// 默认值:家目录(~/.akasha);sessionsRoot 按你的宿主实际会话档案目录配置。
|
|
15
|
-
|
|
16
|
+
// 库根:`AKASHA_DIR` 环境变量 → 家目录下的 .akasha(2026-10-10:一处配置贯通 log/store/meta)。
|
|
17
|
+
const ENV_AKASHA = typeof process.env.AKASHA_DIR === 'string' && process.env.AKASHA_DIR.trim() !== '' ? process.env.AKASHA_DIR.trim() : null;
|
|
18
|
+
const AKASHA_HOME = ENV_AKASHA ? (ENV_AKASHA === '~' ? homedir() : (ENV_AKASHA.startsWith('~/') || ENV_AKASHA.startsWith('~\\') ? join(homedir(), ENV_AKASHA.slice(2)) : ENV_AKASHA)) : join(homedir(), '.akasha');
|
|
16
19
|
const DEFAULTS = {
|
|
17
20
|
log: join(AKASHA_HOME, 'logs', 'hooks.jsonl'),
|
|
18
21
|
akashaDir: AKASHA_HOME,
|
|
@@ -24,7 +27,9 @@ const DEFAULTS = {
|
|
|
24
27
|
contextOrder: 134,
|
|
25
28
|
loopWatch: true,
|
|
26
29
|
watchMinChars: 200,
|
|
27
|
-
watchCheckEvery: 480
|
|
30
|
+
watchCheckEvery: 480,
|
|
31
|
+
indexMode: 'worker', // 'worker'(v0.4 默认)| 'inline'(回退)
|
|
32
|
+
workerRespawnMs: 15000
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
/** 去抖判定(纯函数)。 */
|
|
@@ -246,6 +251,94 @@ export function apply(ctx, config = {}) {
|
|
|
246
251
|
for (const c of compacts) if (c.session === sid && c.indexed !== true) { c.indexed = true; c.reason = 'recovered'; c.recoveredBy = trigger; }
|
|
247
252
|
};
|
|
248
253
|
|
|
254
|
+
// ---------- 索引 worker(v0.4):宿主只投递任务,重活在后台线程串行跑 ----------
|
|
255
|
+
// 协议见 lib/index-worker.mjs;降级链:worker → (连续失败或 spawn 失败)→ inline 旧路径。
|
|
256
|
+
const workerUrl = new URL('./index-worker.mjs', import.meta.url);
|
|
257
|
+
let worker = null;
|
|
258
|
+
let workerFails = 0;
|
|
259
|
+
let workerMode = cfg.indexMode === 'inline' ? 'inline' : 'worker';
|
|
260
|
+
let quitting = false;
|
|
261
|
+
let respawnTimer = null;
|
|
262
|
+
let jobSeq = 0;
|
|
263
|
+
const pendingJobs = new Map(); // id -> { sid, trigger, extra, hostMs, stat }
|
|
264
|
+
|
|
265
|
+
const queueDepth = () => pendingJobs.size;
|
|
266
|
+
|
|
267
|
+
const scheduleRespawn = () => {
|
|
268
|
+
if (respawnTimer || quitting || workerMode !== 'worker') return;
|
|
269
|
+
respawnTimer = setTimeout(() => { respawnTimer = null; spawnWorker(); }, cfg.workerRespawnMs);
|
|
270
|
+
if (typeof respawnTimer.unref === 'function') respawnTimer.unref();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const onWorkerMessage = (msg) => {
|
|
274
|
+
if (!msg || typeof msg !== 'object') return;
|
|
275
|
+
if (msg.type === 'ready') { log({ kind: 'session-worker', event: 'ready', node: msg.node }); return; }
|
|
276
|
+
if (msg.type === 'queued') { log({ kind: 'session-worker', event: 'queued', session: msg.session, depth: msg.depth, running: msg.running }); return; }
|
|
277
|
+
if (msg.type === 'bye') { log({ kind: 'session-worker', event: 'bye', processed: msg.processed }); return; }
|
|
278
|
+
if (msg.type !== 'result') return;
|
|
279
|
+
const p = pendingJobs.get(msg.id);
|
|
280
|
+
pendingJobs.delete(msg.id);
|
|
281
|
+
const extra = p?.extra ?? {};
|
|
282
|
+
if (msg.error) {
|
|
283
|
+
lastFailAt.set(msg.session, Date.now());
|
|
284
|
+
log({ kind: 'session-index-error', session: msg.session, trigger: msg.trigger, message: msg.error, workerMs: msg.ms, hostMs: p?.hostMs ?? null, via: 'worker' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (p?.stat) lastStat.set(msg.session, { size: p.stat.size, mtimeMs: p.stat.mtimeMs }); // 只记成功:失败后同档可重试
|
|
288
|
+
workerFails = 0; // 「连续失败」语义:任何一次成功都回零(2026-10-10 复查修正)
|
|
289
|
+
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' });
|
|
290
|
+
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' });
|
|
291
|
+
settleCompacts(msg.session, msg.trigger);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
function spawnWorker() {
|
|
295
|
+
if (workerMode !== 'worker' || worker || quitting) return;
|
|
296
|
+
try {
|
|
297
|
+
worker = new Worker(workerUrl);
|
|
298
|
+
worker.unref?.();
|
|
299
|
+
log({ kind: 'session-worker', event: 'spawn', pid: process.pid, pending: queueDepth() });
|
|
300
|
+
worker.on('message', (msg) => { try { onWorkerMessage(msg); } catch { /* 观测失败不拖宿主 */ } });
|
|
301
|
+
worker.on('error', (err) => {
|
|
302
|
+
log({ kind: 'session-worker-error', event: 'error', message: String(err?.message ?? err).slice(0, 300) });
|
|
303
|
+
worker = null;
|
|
304
|
+
workerFails += 1;
|
|
305
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
306
|
+
else scheduleRespawn();
|
|
307
|
+
});
|
|
308
|
+
worker.on('exit', (code) => {
|
|
309
|
+
const lost = pendingJobs.size;
|
|
310
|
+
if (lost) pendingJobs.clear();
|
|
311
|
+
log({ kind: 'session-worker', event: 'exit', code, lost, quitting });
|
|
312
|
+
worker = null;
|
|
313
|
+
if (!quitting) scheduleRespawn();
|
|
314
|
+
});
|
|
315
|
+
} catch (e) {
|
|
316
|
+
worker = null;
|
|
317
|
+
log({ kind: 'session-worker-error', event: 'spawn', message: String(e?.message ?? e).slice(0, 300) });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
spawnWorker(); // 预热:apply 即起线程,首单不付冷启动
|
|
321
|
+
|
|
322
|
+
/** 投递一个索引任务;返回 { queued:true, hostMs } 或 { queued:false }(走 inline 回退)。 */
|
|
323
|
+
const dispatchIndex = (sid, file, trigger, extra, opts, st) => {
|
|
324
|
+
if (workerMode !== 'worker') return { queued: false };
|
|
325
|
+
spawnWorker();
|
|
326
|
+
if (!worker) return { queued: false };
|
|
327
|
+
const id = ++jobSeq;
|
|
328
|
+
const t0 = Date.now();
|
|
329
|
+
try {
|
|
330
|
+
worker.postMessage({ type: 'index', id, job: { akashaDir: cfg.akashaDir, session: sid, file, storeFile: cfg.storeFile, metaFile: cfg.metaFile, trigger, full: !!opts.full } });
|
|
331
|
+
const hostMs = Date.now() - t0;
|
|
332
|
+
pendingJobs.set(id, { sid, trigger, extra, hostMs, stat: st });
|
|
333
|
+
return { queued: true, hostMs };
|
|
334
|
+
} catch (e) {
|
|
335
|
+
workerFails += 1;
|
|
336
|
+
log({ kind: 'session-worker-error', event: 'post', message: String(e?.message ?? e).slice(0, 200) });
|
|
337
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
338
|
+
return { queued: false };
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
249
342
|
/**
|
|
250
343
|
* 返回索引结论(B1):{ ok:true, reason:'indexed'|'unchanged' } = 档案内容已在会话层;
|
|
251
344
|
* { ok:false, reason:'no-session'|'backoff'|'not-found'|'error' } = 没收进去;
|
|
@@ -278,11 +371,17 @@ export function apply(ctx, config = {}) {
|
|
|
278
371
|
}
|
|
279
372
|
}
|
|
280
373
|
const t0 = Date.now();
|
|
374
|
+
const disp = dispatchIndex(sid, file, trigger, extra, opts, st);
|
|
375
|
+
if (disp.queued) {
|
|
376
|
+
// 宿主只投递(v0.4):结果由 worker 回传,落地线条在 onWorkerMessage
|
|
377
|
+
return { ok: null, reason: 'queued', hostMs: disp.hostMs };
|
|
378
|
+
}
|
|
379
|
+
// 回退:inline 旧路径(indexMode:'inline' / worker 不可用 / 连续失败降级)
|
|
281
380
|
const r = core().indexSession({ file, session: sid, storeFile: cfg.storeFile, metaFile: cfg.metaFile });
|
|
282
381
|
if (st) lastStat.set(sid, { size: st.size, mtimeMs: st.mtimeMs }); // 只记成功:失败后同档可重试
|
|
283
382
|
const dur = Date.now() - t0;
|
|
284
|
-
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 });
|
|
285
|
-
if (dur > 1500) log({ kind: 'session-index-slow', session: sid, trigger, ms: dur, lagMs: loopLagMs, added: r.added }); // >1.5s 告警线(2026-10-07 补4)
|
|
383
|
+
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' });
|
|
384
|
+
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)
|
|
286
385
|
settleCompacts(sid, trigger);
|
|
287
386
|
return { ok: true, reason: 'indexed', added: r.added };
|
|
288
387
|
} catch (error) {
|
|
@@ -336,6 +435,15 @@ export function apply(ctx, config = {}) {
|
|
|
336
435
|
} catch { /* 静默 */ }
|
|
337
436
|
}
|
|
338
437
|
|
|
438
|
+
// 退出协议(v0.4):通知 worker drain 后关闭;5s 未退则强杀。
|
|
439
|
+
ctx.effect(() => () => {
|
|
440
|
+
quitting = true;
|
|
441
|
+
try { worker?.postMessage({ type: 'quit' }); } catch { /* ignore */ }
|
|
442
|
+
const w = worker;
|
|
443
|
+
const t = setTimeout(() => { try { w?.terminate(); } catch { /* ignore */ } }, 5000);
|
|
444
|
+
if (typeof t.unref === 'function') t.unref();
|
|
445
|
+
});
|
|
446
|
+
|
|
339
447
|
// 条子:每回合一行(无内容静默)。
|
|
340
448
|
ctx.systemPrompt.context({
|
|
341
449
|
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.1",
|
|
4
|
+
"repository": { "type": "git", "url": "https://github.com/Ersith/akasha-book.git", "directory": "plugins/akasha-session" },
|
|
4
5
|
"engines": { "node": ">=22.15.0" },
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"type": "module",
|
|
@@ -10,12 +11,21 @@
|
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"lib/index.js",
|
|
14
|
+
"lib/index-worker.mjs",
|
|
13
15
|
"cordis.patch.yml",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
18
|
+
"scripts": { "test": "node selftest.test.mjs" },
|
|
16
19
|
"dsh": {
|
|
17
20
|
"bundle": {
|
|
18
21
|
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"compatibility": {
|
|
24
|
+
"dshReleases": {
|
|
25
|
+
"0.2.0-rc.2": "compatible",
|
|
26
|
+
"0.2.1-alpha.1": "compatible",
|
|
27
|
+
"0.2.1-alpha.2": "compatible"
|
|
28
|
+
}
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
}
|