pi-think-anchor 1.0.2 → 1.0.4

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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/index.ts +145 -42
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 来源与借鉴
6
6
 
7
- 蒸馏自 [dsh-anchored-monitor](https://dsh-plugin.org/zh/plugins/aik358/dsh-anchored-monitor) 0.3.1(DSH 生态):机制思想来源,非代码移植(回合级而非流内实时、无独立进程)。判据 v2(证据门槛 + 发散占比)为本机 6151 条日志校准后的本地改进。
7
+ 蒸馏自 [dsh-anchored-monitor](https://dsh-plugin.org/zh/plugins/aik358/dsh-anchored-monitor) 0.3.1(DSH 生态):机制思想来源,非代码移植(回合级而非流内实时、无独立进程)。判据经 6151 条日志三次校准:**v2.3 = 单轮证据门槛 + α 平滑专注/发散比**(α=1;react <0.5 / mixed <1.2 / spec ≥1.2)。命中不足的轮次不评分(避免把「长而散」判成反复试探),回放 react 11.3% → 3.4%。
8
8
 
9
9
  ## 功能
10
10
 
@@ -19,7 +19,8 @@
19
19
  | 项 | 默认 | 说明 |
20
20
  | --- | --- | --- |
21
21
  | 启用 | 开 | `THINK_ANCHOR=0` 或配置文件 `enabled: false` 关闭 |
22
- | 波段阈值 | 1.2 / 0.5 | 高专注 ≥1.2;发散 <0.5 |
22
+ | 波段阈值 | 1.2 / 0.5 | α 平滑比率:高专注 ≥1.2;发散 <0.5 |
23
+ | 证据门槛 | 3 命中/轮 | 本轮词频命中不足 3 个 → 不评分、不干预 |
23
24
  | 聚合窗口 | 5 轮 | 滑动窗口 |
24
25
  | L1 冷却 | 10 分钟 | 首次跌入发散 |
25
26
  | L2 冷却 | 15 分钟 | 持续发散,会话上限 2 次 |
@@ -28,7 +29,7 @@
28
29
  ## 安装
29
30
 
30
31
  ```bash
31
- pi install npm:pi-think-anchor@1.0.0
32
+ pi install npm:pi-think-anchor
32
33
  pi install ./pi-think-anchor
33
34
  ```
34
35
 
package/index.ts CHANGED
@@ -31,7 +31,7 @@ interface TAConfig {
31
31
  windowSize: number;
32
32
  bands: { spec: number; react: number };
33
33
  /** v2 主判据:证据门槛 + 发散词占比(与长度无关) */
34
- score: { minHits: number; specRate: number; reactRate: number };
34
+ score: { minHits: number; spec: number; react: number; alpha: number };
35
35
  weights: { beta: number; eps: number };
36
36
  intervention: { l1CooldownMin: number; l2CooldownMin: number; maxL2: number };
37
37
  }
@@ -41,7 +41,7 @@ const DEF_CFG: TAConfig = {
41
41
  lexicon: { positive: DEF_POS, negative: DEF_NEG, neutral: DEF_NEU },
42
42
  windowSize: 5,
43
43
  bands: { spec: 1.2, react: 0.5 },
44
- score: { minHits: 3, specRate: 0.25, reactRate: 0.5 },
44
+ score: { minHits: 3, spec: 1.2, react: 0.5, alpha: 1 },
45
45
  weights: { beta: 0.4, eps: 0.5 },
46
46
  intervention: { l1CooldownMin: 10, l2CooldownMin: 15, maxL2: 2 },
47
47
  };
@@ -58,6 +58,8 @@ function loadCfg(): TAConfig {
58
58
  ...DEF_CFG,
59
59
  ...raw,
60
60
  lexicon: { ...DEF_CFG.lexicon, ...(raw.lexicon ?? {}) },
61
+ // score 是 v2 判据;未配置 score 时兼容旧 bands(否则改 bands 静默无效)
62
+ score: { ...DEF_CFG.score, ...(raw.score ?? {}), ...(raw.score ? {} : (raw.bands ?? {})) },
61
63
  bands: { ...DEF_CFG.bands, ...(raw.bands ?? {}) },
62
64
  weights: { ...DEF_CFG.weights, ...(raw.weights ?? {}) },
63
65
  intervention: { ...DEF_CFG.intervention, ...(raw.intervention ?? {}) },
@@ -103,15 +105,17 @@ export function bandOf(ratio: number, bands: { spec: number; react: number } = D
103
105
  }
104
106
 
105
107
  /**
106
- * v2 判据:先过证据门槛,再看发散词占比 negRate = neg/(pos+neg+neu)。
107
- * 数据依据(本机 6151 条实测):react 行 thinking 长度中位 1998、词命中中位 2,43% 的 neg<=1
108
- * —— 旧 ratio(pos/(neg+eps))把「长而散」误判成「反复试探」:571 次 react 里 326 次证据不足。
109
- * negRate 分离度:spec 中位 0.14 / mixed 0.50 / react 1.00。
108
+ * v2.1 判据:证据门槛 + α 平滑的专注/发散比。
109
+ * 回放 2069 条可评分日志:旧判据 react 占 18.3%(其中 57% 是命中 <3 的长而散);
110
+ * α=1(伪计数)+ minHits=3 后 react 占 9.7%,且选中样本 negRate 中位 0.86(真发散)。
111
+ * α 的作用:低命中时比率趋向 1(mixed),不再因「命中少」被误判成 react。
110
112
  */
111
- export function classify(hits: number, negRate: number, c: { minHits: number; specRate: number; reactRate: number }): TABand {
113
+ export function classify(pos: number, neg: number, neu: number, c: { minHits: number; spec: number; react: number; alpha: number }): TABand {
114
+ const hits = pos + neg + neu;
112
115
  if (hits < c.minHits) return "none";
113
- if (negRate >= c.reactRate) return "react";
114
- if (negRate >= c.specRate) return "mixed";
116
+ const r = (pos + 0.4 * neu + c.alpha) / (neg + 0.5 + c.alpha);
117
+ if (r < c.react) return "react";
118
+ if (r < c.spec) return "mixed";
115
119
  return "spec";
116
120
  }
117
121
 
@@ -164,6 +168,11 @@ export function decideNext(phase: TAPhase, band: TABand, l2Tried: number, maxL2:
164
168
  }
165
169
 
166
170
  // ── 干预文案(中性建议式——原版措辞纪律:不指责、不自称审查者) ──
171
+ export function downgradeIfIneffective(level: "none" | "L1" | "L2", lastEff: number | null): "none" | "L1" | "L2" {
172
+ if (level === "L2" && lastEff !== null && lastEff >= 0) return "L1";
173
+ return level;
174
+ }
175
+
167
176
  const L1_TEXT = "「think-anchor」信号(词频启发式,非判据):思考偏离收敛。动作:给结论 + 下一步,只留依据。";
168
177
  const L2_TEXT = "「think-anchor」信号(词频启发式,非判据):连续偏离。动作:停止探索,立即执行一个具体动作。";
169
178
 
@@ -176,11 +185,19 @@ interface TAState {
176
185
  l1Count: number;
177
186
  l2Count: number;
178
187
  turns: number;
179
- bandHist: { spec: number; mixed: number; react: number };
188
+ bandHist: { spec: number; mixed: number; react: number; none: number };
180
189
  lastL1: number;
181
190
  lastL2: number;
182
191
  lastRatio: number;
183
192
  lastBand: TABand;
193
+ /** v3:效果闭环与自适应(借鉴 ptc-meter 的数据自校正 + patch-keeper 的条件触发) */
194
+ recent: number[];
195
+ pending: { turn: number; pre: number; ts: number; seen: number } | null;
196
+ effSamples: number;
197
+ effImproved: number;
198
+ autoMuted: boolean;
199
+ lastLoggedBand: TABand;
200
+ skipped: number;
184
201
  }
185
202
 
186
203
  function newState(cfg: TAConfig): TAState {
@@ -192,14 +209,29 @@ function newState(cfg: TAConfig): TAState {
192
209
  l1Count: 0,
193
210
  l2Count: 0,
194
211
  turns: 0,
195
- bandHist: { spec: 0, mixed: 0, react: 0 },
212
+ bandHist: { spec: 0, mixed: 0, react: 0, none: 0 },
196
213
  lastL1: 0,
197
214
  lastL2: 0,
198
215
  lastRatio: 0,
199
216
  lastBand: "mixed",
217
+ recent: [],
218
+ pending: null,
219
+ effSamples: 0,
220
+ effImproved: 0,
221
+ autoMuted: false,
222
+ lastLoggedBand: "mixed",
223
+ skipped: 0,
200
224
  };
201
225
  }
202
226
 
227
+ /** 采样写盘(v3):非事件轮每 5 轮留一行——实测 6151 行 / 1.0 MB 逼近 1 MiB 上限,降为 1/5 仍够统计 */
228
+ const SAMPLE_EVERY = 5;
229
+ /** 自适应(v3):有效样本 ≥ AUTO_MIN 且下降率 < AUTO_RATE(不及抛硬币)即转静默,只记录不注入 */
230
+ const AUTO_MIN = 8;
231
+ const AUTO_RATE = 0.5;
232
+ /** 上次干预的实测效果(eff,负 = 改善);用于「无效则不升级」 */
233
+ let lastEffValue: number | null = null;
234
+
203
235
  /** 日志上限 1 MiB(与 ptc-meter 同口径):超限保留尾部,避免无界增长。 */
204
236
  const MAX_LOG = 1 << 20;
205
237
 
@@ -207,9 +239,10 @@ export function appendLog(row: Record<string, unknown>): void {
207
239
  try {
208
240
  appendFileSync(LOG, JSON.stringify(row) + "\n");
209
241
  if (statSync(LOG).size > MAX_LOG) {
210
- const keep = readFileSync(LOG, "utf8").slice(-MAX_LOG);
211
- const cut = keep.indexOf("\n");
212
- writeFileSync(LOG, cut >= 0 ? keep.slice(cut + 1) : keep);
242
+ const buf = readFileSync(LOG);
243
+ const keep = buf.subarray(Math.max(0, buf.length - MAX_LOG));
244
+ const cut = keep.indexOf(10);
245
+ writeFileSync(LOG, cut >= 0 ? keep.subarray(cut + 1) : keep);
213
246
  }
214
247
  } catch {
215
248
  /* 静默 */
@@ -220,7 +253,12 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
220
253
  let st = newState(loadCfg());
221
254
 
222
255
  pi.on("session_start", () => {
256
+ // 跨会话保住效果闭环:干预常发生在会话末尾,3 轮结算窗口会跨过 session 边界
257
+ const carryRecent = st.recent.slice(-3);
258
+ const carryPending = st.pending;
223
259
  st = newState(loadCfg());
260
+ st.recent = carryRecent;
261
+ st.pending = carryPending;
224
262
  });
225
263
 
226
264
  pi.on("turn_end", async (event, ctx) => {
@@ -234,7 +272,18 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
234
272
  const fp = fingerprint(thinking, st.cfg.lexicon);
235
273
  if (fp.pos + fp.neg + fp.neu === 0) {
236
274
  st.turns++;
237
- appendLog({ ts: new Date().toISOString(), turn: st.turns, v: 2, len: thinking.length, pos: 0, neg: 0, neu: 0, ratio: null, hits: 0, negRate: null, band: "none", phase: st.phase, want: "none", acted: "none" });
275
+ if (st.turns % SAMPLE_EVERY === 0) appendLog({ ts: new Date().toISOString(), turn: st.turns, v: 3, len: thinking.length, pos: 0, neg: 0, neu: 0, ratio: null, hits: 0, negRate: null, band: "none", phase: st.phase, want: "none", acted: "none", eff: null });
276
+ return;
277
+ }
278
+ // v2.3:证据门槛作用于**本轮**。窗口聚合 hits 中位 27(仅 1.4% 低于门槛)→ 门槛形同虚设;
279
+ // 而 42.7% 的轮次单轮命中 <3(多为长而散),正是旧口径下 react 偏高的来源(回放 11.3% → 3.4%)。
280
+ const turnHits = fp.pos + fp.neg + fp.neu;
281
+ if (turnHits < st.cfg.score.minHits) {
282
+ st.bandHist.none++; // 会话内计数:v3 采样下 none 轮通常不写日志,只有这里能观测 v2.3 生效
283
+ st.window.push(fp);
284
+ if (st.window.length > st.cfg.windowSize) st.window.shift();
285
+ st.turns++;
286
+ if (st.turns % SAMPLE_EVERY === 0) appendLog({ ts: new Date().toISOString(), turn: st.turns, v: 3, len: thinking.length, pos: fp.pos, neg: fp.neg, neu: fp.neu, ratio: null, hits: turnHits, negRate: null, band: "none", phase: st.phase, want: "none", acted: "none", eff: null });
238
287
  return;
239
288
  }
240
289
  st.window.push(fp);
@@ -245,65 +294,95 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
245
294
  lastHits = hits;
246
295
  lastNegRate = negRate;
247
296
  const ratio = ratioOf(agg.pos, agg.neg, agg.neu, st.cfg.weights);
248
- const band = classify(hits, negRate, st.cfg.score);
297
+ const band = classify(agg.pos, agg.neg, agg.neu, st.cfg.score);
249
298
  const prevPhase = st.phase;
250
- const d = decideNext(st.phase, band, st.l2Tried, st.cfg.intervention.maxL2);
299
+ const r = (agg.pos + 0.4 * agg.neu + st.cfg.score.alpha) / (agg.neg + 0.5 + st.cfg.score.alpha);
300
+ const d0 = decideNext(st.phase, band, st.l2Tried, st.cfg.intervention.maxL2);
301
+ // 升级前先看上次干预有没有用:无效则本次不升 L2(实测 L2 无收益)
302
+ const d = { ...d0, level: downgradeIfIneffective(d0.level, lastEffValue) };
251
303
  st.turns++;
252
- st.lastRatio = ratio;
304
+ st.lastRatio = r;
253
305
  st.lastBand = band;
254
306
  if (band !== "none") st.bandHist[band]++; // none = 证据不足,不计波段(否则 NaN)
255
307
  let acted = "none";
256
308
  const now = Date.now();
257
309
  if (d.level === "none") {
258
310
  st.phase = d.phase;
259
- } else if (d.level === "L1" && now - st.lastL1 > st.cfg.intervention.l1CooldownMin * 60_000) {
311
+ } else if (d.level === "L1" && !st.autoMuted && now - st.lastL1 > st.cfg.intervention.l1CooldownMin * 60_000) {
260
312
  st.lastL1 = now;
261
313
  st.l1Count++;
262
314
  (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
263
- { customType: "think-anchor", content: L1_TEXT + `(发散占比 ${negRate.toFixed(2)} · 命中 ${hits})`, display: false },
315
+ { customType: "think-anchor", content: L1_TEXT + `(比率 ${r.toFixed(2)} · 命中 ${hits};react<0.5)`, display: false },
264
316
  { deliverAs: "nextTurn" },
265
317
  );
266
318
  acted = "L1";
319
+ st.pending = { turn: st.turns, pre: st.recent.slice(-3).reduce((a, b) => a + b, 0) / Math.max(1, Math.min(3, st.recent.length)), ts: Date.now(), seen: 0 };
267
320
  st.phase = d.phase;
268
- } else if (d.level === "L2" && now - st.lastL2 > st.cfg.intervention.l2CooldownMin * 60_000) {
321
+ } else if (d.level === "L2" && !st.autoMuted && now - st.lastL2 > st.cfg.intervention.l2CooldownMin * 60_000) {
269
322
  st.lastL2 = now;
270
323
  st.l2Tried++;
271
324
  st.l2Count++;
272
325
  (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
273
- { customType: "think-anchor", content: L2_TEXT + `(发散占比 ${negRate.toFixed(2)} · 命中 ${hits},L2 ${st.l2Tried}/${st.cfg.intervention.maxL2})`, display: false },
326
+ { customType: "think-anchor", content: L2_TEXT + `(比率 ${r.toFixed(2)} · 命中 ${hits};L2 ${st.l2Tried}/${st.cfg.intervention.maxL2})`, display: false },
274
327
  { deliverAs: "nextTurn" },
275
328
  );
276
329
  acted = "L2";
330
+ st.pending = { turn: st.turns, pre: st.recent.slice(-3).reduce((a, b) => a + b, 0) / Math.max(1, Math.min(3, st.recent.length)), ts: Date.now(), seen: 0 };
277
331
  st.phase = d.phase;
278
332
  } else {
279
333
  // 被冷却/上限挡下:记 blocked,避免与「无需动作」混淆(实测 583 次 want=L2 只有 55 次真发)
280
334
  st.phase = prevPhase;
281
- if (d.level !== "none") acted = "blocked-" + d.level;
335
+ if (d.level !== "none") acted = st.autoMuted ? "muted-" + d.level : "blocked-" + d.level;
282
336
  }
283
337
  if (band === "spec" && st.phase === "healthy") st.l2Tried = 0; // 恢复后重置 L2 计数
284
- appendLog({
285
- ts: new Date().toISOString(),
286
- turn: st.turns,
287
- v: 2,
288
- len: thinking.length,
289
- pos: fp.pos,
290
- neg: fp.neg,
291
- neu: fp.neu,
292
- hits,
293
- negRate: Math.round(negRate * 100) / 100,
294
- ratio: Math.round(ratio * 100) / 100,
295
- band,
296
- phase: st.phase,
297
- want: d.level,
298
- acted,
299
- });
338
+ // v3 效果闭环:干预后第 3 轮结算(pre = 干预前最近 3 轮 ratio 均值)
339
+ st.recent.push(ratio);
340
+ if (st.recent.length > 6) st.recent.shift();
341
+ if (st.pending) st.pending.seen++; // 用独立计数而非 turns(新会话 turns 归零,差值会为负)
342
+ let eff: number | null = null;
343
+ // 跨会话只在 30 分钟内结算(避免隔天比率混算)
344
+ const pendingFresh = st.pending !== null && Date.now() - st.pending.ts < 30 * 60_000;
345
+ if (st.pending && pendingFresh && st.pending.seen >= 3) {
346
+ const tail = st.recent.slice(-3);
347
+ const post = tail.reduce((a, b) => a + b, 0) / Math.max(1, tail.length);
348
+ eff = Math.round((st.pending.pre - post) * 100) / 100; // 负 = 改善(比率上升)
349
+ lastEffValue = eff;
350
+ st.effSamples++;
351
+ if (eff < 0) st.effImproved++;
352
+ st.pending = null;
353
+ // 自适应:样本够且实测下降率不及抛硬币 → 转静默(只记录,不注入)
354
+ st.autoMuted = st.effSamples >= AUTO_MIN && st.effImproved / st.effSamples < AUTO_RATE;
355
+ }
356
+ // 采样写盘:事件轮(有干预/有结算/波段变化)必写,其余每 SAMPLE_EVERY 轮一行
357
+ const eventful = acted !== "none" || eff !== null || band !== st.lastLoggedBand;
358
+ if (!eventful && st.turns % SAMPLE_EVERY !== 0) { st.skipped++; }
359
+ else {
360
+ st.lastLoggedBand = band;
361
+ appendLog({
362
+ ts: new Date().toISOString(),
363
+ turn: st.turns,
364
+ v: 3,
365
+ len: thinking.length,
366
+ pos: fp.pos,
367
+ neg: fp.neg,
368
+ neu: fp.neu,
369
+ hits,
370
+ negRate: Math.round(negRate * 100) / 100,
371
+ ratio: Math.round(r * 100) / 100,
372
+ band,
373
+ phase: st.phase,
374
+ want: d.level,
375
+ acted,
376
+ eff,
377
+ });
378
+ }
300
379
  } catch {
301
380
  /* 绝不抛错 */
302
381
  }
303
382
  });
304
383
 
305
384
  pi.registerCommand("think-anchor", {
306
- description: "思维锚定监测:状态 / on / off / reset",
385
+ description: "思维锚定监测:状态 / stats(日志统计)/ on / off / reset",
307
386
  handler: async (args, ctx) => {
308
387
  const sub = (args || "").trim().toLowerCase();
309
388
  if (sub === "on") {
@@ -321,14 +400,38 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
321
400
  if (ctx.hasUI) ctx.ui.notify("think-anchor: 会话状态已重置。", "info");
322
401
  return;
323
402
  }
403
+ if (sub === "stats") {
404
+ try {
405
+ const txt = readFileSync(LOG, "utf8");
406
+ const rows = txt.split("\n").filter((l) => l.trim()).map((l) => { try { return JSON.parse(l) as Record<string, unknown>; } catch { return null; } }).filter(Boolean) as Array<Record<string, unknown>>;
407
+ const group = (k: string) => {
408
+ const m = new Map<string, number>();
409
+ for (const r of rows) { const key = String(r[k] ?? "?"); m.set(key, (m.get(key) ?? 0) + 1); }
410
+ return [...m.entries()].map(([a, b]) => a + "=" + b).join(" ") || "-";
411
+ };
412
+ const effRows = rows.filter((r) => typeof r.eff === "number");
413
+ const improved = effRows.filter((r) => (r.eff as number) < 0).length;
414
+ const size = (statSync(LOG).size / 1048576).toFixed(2);
415
+ const lines = [
416
+ "think-anchor 统计:" + size + " MB / 上限 1.00 · " + rows.length + " 行",
417
+ " 版本 " + group("v") + " · 波段 " + group("band"),
418
+ " 动作 " + group("acted") + " · want " + group("want"),
419
+ " 效果结算 " + improved + "/" + effRows.length + (effRows.length ? "(下降 " + Math.round((100 * improved) / effRows.length) + "%)" : "(样本未满:干预后需再走 3 轮)"),
420
+ ];
421
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
422
+ } catch { if (ctx.hasUI) ctx.ui.notify("think-anchor: 统计失败(日志读取异常)", "warning"); }
423
+ return;
424
+ }
324
425
  const line =
325
426
  "think-anchor:" +
326
427
  (st.cfg.enabled ? "ON" : "OFF") +
327
428
  " · phase=" + st.phase +
328
429
  " · 消息 " + st.turns +
329
- " · 波段 spec/mixed/react=" + st.bandHist.spec + "/" + st.bandHist.mixed + "/" + st.bandHist.react +
430
+ " · 波段 spec/mixed/react/none=" + st.bandHist.spec + "/" + st.bandHist.mixed + "/" + st.bandHist.react + "/" + st.bandHist.none +
330
431
  " · 干预 L1×" + st.l1Count + " L2×" + st.l2Count +
331
- " · 上轮 命中" + lastHits + " 发散占比" + (Math.round(lastNegRate * 100) / 100) + "(" + st.lastBand + ")";
432
+ " · 有效率 " + st.effImproved + "/" + st.effSamples + (st.autoMuted ? "(不及抛硬币→已静默)" : st.effSamples === 0 ? "(样本未满:干预后 3 轮结算)" : st.effSamples < AUTO_MIN ? "(样本 " + st.effSamples + "/" + AUTO_MIN + ")" : "") +
433
+ " · 上轮 命中" + lastHits + " 发散占比" + (Math.round(lastNegRate * 100) / 100) + "(" + st.lastBand + ")" +
434
+ " · 采样跳过 " + st.skipped;
332
435
  if (ctx.hasUI) ctx.ui.notify(line, "info");
333
436
  },
334
437
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-think-anchor",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Monitors thinking focus drift and nudges the model back on track.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,5 +22,6 @@
22
22
  ],
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-coding-agent": "*"
25
- }
25
+ },
26
+ "author": "15066212pp"
26
27
  }