pi-think-anchor 1.0.3 → 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 +3 -2
  2. package/index.ts +63 -28
  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 次 |
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,14 +185,14 @@ 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;
184
193
  /** v3:效果闭环与自适应(借鉴 ptc-meter 的数据自校正 + patch-keeper 的条件触发) */
185
194
  recent: number[];
186
- pending: { turn: number; pre: number } | null;
195
+ pending: { turn: number; pre: number; ts: number; seen: number } | null;
187
196
  effSamples: number;
188
197
  effImproved: number;
189
198
  autoMuted: boolean;
@@ -200,7 +209,7 @@ function newState(cfg: TAConfig): TAState {
200
209
  l1Count: 0,
201
210
  l2Count: 0,
202
211
  turns: 0,
203
- bandHist: { spec: 0, mixed: 0, react: 0 },
212
+ bandHist: { spec: 0, mixed: 0, react: 0, none: 0 },
204
213
  lastL1: 0,
205
214
  lastL2: 0,
206
215
  lastRatio: 0,
@@ -220,6 +229,8 @@ const SAMPLE_EVERY = 5;
220
229
  /** 自适应(v3):有效样本 ≥ AUTO_MIN 且下降率 < AUTO_RATE(不及抛硬币)即转静默,只记录不注入 */
221
230
  const AUTO_MIN = 8;
222
231
  const AUTO_RATE = 0.5;
232
+ /** 上次干预的实测效果(eff,负 = 改善);用于「无效则不升级」 */
233
+ let lastEffValue: number | null = null;
223
234
 
224
235
  /** 日志上限 1 MiB(与 ptc-meter 同口径):超限保留尾部,避免无界增长。 */
225
236
  const MAX_LOG = 1 << 20;
@@ -228,9 +239,10 @@ export function appendLog(row: Record<string, unknown>): void {
228
239
  try {
229
240
  appendFileSync(LOG, JSON.stringify(row) + "\n");
230
241
  if (statSync(LOG).size > MAX_LOG) {
231
- const keep = readFileSync(LOG, "utf8").slice(-MAX_LOG);
232
- const cut = keep.indexOf("\n");
233
- 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);
234
246
  }
235
247
  } catch {
236
248
  /* 静默 */
@@ -241,7 +253,12 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
241
253
  let st = newState(loadCfg());
242
254
 
243
255
  pi.on("session_start", () => {
256
+ // 跨会话保住效果闭环:干预常发生在会话末尾,3 轮结算窗口会跨过 session 边界
257
+ const carryRecent = st.recent.slice(-3);
258
+ const carryPending = st.pending;
244
259
  st = newState(loadCfg());
260
+ st.recent = carryRecent;
261
+ st.pending = carryPending;
245
262
  });
246
263
 
247
264
  pi.on("turn_end", async (event, ctx) => {
@@ -258,6 +275,17 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
258
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 });
259
276
  return;
260
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 });
287
+ return;
288
+ }
261
289
  st.window.push(fp);
262
290
  if (st.window.length > st.cfg.windowSize) st.window.shift();
263
291
  const agg = st.window.reduce((a, b) => ({ pos: a.pos + b.pos, neg: a.neg + b.neg, neu: a.neu + b.neu }), { pos: 0, neg: 0, neu: 0 });
@@ -266,11 +294,14 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
266
294
  lastHits = hits;
267
295
  lastNegRate = negRate;
268
296
  const ratio = ratioOf(agg.pos, agg.neg, agg.neu, st.cfg.weights);
269
- const band = classify(hits, negRate, st.cfg.score);
297
+ const band = classify(agg.pos, agg.neg, agg.neu, st.cfg.score);
270
298
  const prevPhase = st.phase;
271
- 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) };
272
303
  st.turns++;
273
- st.lastRatio = ratio;
304
+ st.lastRatio = r;
274
305
  st.lastBand = band;
275
306
  if (band !== "none") st.bandHist[band]++; // none = 证据不足,不计波段(否则 NaN)
276
307
  let acted = "none";
@@ -281,22 +312,22 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
281
312
  st.lastL1 = now;
282
313
  st.l1Count++;
283
314
  (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
284
- { 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 },
285
316
  { deliverAs: "nextTurn" },
286
317
  );
287
318
  acted = "L1";
288
- 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)) };
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 };
289
320
  st.phase = d.phase;
290
321
  } else if (d.level === "L2" && !st.autoMuted && now - st.lastL2 > st.cfg.intervention.l2CooldownMin * 60_000) {
291
322
  st.lastL2 = now;
292
323
  st.l2Tried++;
293
324
  st.l2Count++;
294
325
  (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
295
- { 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 },
296
327
  { deliverAs: "nextTurn" },
297
328
  );
298
329
  acted = "L2";
299
- 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)) };
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 };
300
331
  st.phase = d.phase;
301
332
  } else {
302
333
  // 被冷却/上限挡下:记 blocked,避免与「无需动作」混淆(实测 583 次 want=L2 只有 55 次真发)
@@ -307,11 +338,15 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
307
338
  // v3 效果闭环:干预后第 3 轮结算(pre = 干预前最近 3 轮 ratio 均值)
308
339
  st.recent.push(ratio);
309
340
  if (st.recent.length > 6) st.recent.shift();
341
+ if (st.pending) st.pending.seen++; // 用独立计数而非 turns(新会话 turns 归零,差值会为负)
310
342
  let eff: number | null = null;
311
- if (st.pending && st.turns - st.pending.turn >= 3) {
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) {
312
346
  const tail = st.recent.slice(-3);
313
347
  const post = tail.reduce((a, b) => a + b, 0) / Math.max(1, tail.length);
314
- eff = Math.round((post - st.pending.pre) * 100) / 100;
348
+ eff = Math.round((st.pending.pre - post) * 100) / 100; // 负 = 改善(比率上升)
349
+ lastEffValue = eff;
315
350
  st.effSamples++;
316
351
  if (eff < 0) st.effImproved++;
317
352
  st.pending = null;
@@ -333,7 +368,7 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
333
368
  neu: fp.neu,
334
369
  hits,
335
370
  negRate: Math.round(negRate * 100) / 100,
336
- ratio: Math.round(ratio * 100) / 100,
371
+ ratio: Math.round(r * 100) / 100,
337
372
  band,
338
373
  phase: st.phase,
339
374
  want: d.level,
@@ -381,7 +416,7 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
381
416
  "think-anchor 统计:" + size + " MB / 上限 1.00 · " + rows.length + " 行",
382
417
  " 版本 " + group("v") + " · 波段 " + group("band"),
383
418
  " 动作 " + group("acted") + " · want " + group("want"),
384
- " 效果结算 " + improved + "/" + effRows.length + (effRows.length ? "(下降 " + Math.round((100 * improved) / effRows.length) + "%)" : ""),
419
+ " 效果结算 " + improved + "/" + effRows.length + (effRows.length ? "(下降 " + Math.round((100 * improved) / effRows.length) + "%)" : "(样本未满:干预后需再走 3 轮)"),
385
420
  ];
386
421
  if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
387
422
  } catch { if (ctx.hasUI) ctx.ui.notify("think-anchor: 统计失败(日志读取异常)", "warning"); }
@@ -392,9 +427,9 @@ export default function thinkAnchor(pi: ExtensionAPI): void {
392
427
  (st.cfg.enabled ? "ON" : "OFF") +
393
428
  " · phase=" + st.phase +
394
429
  " · 消息 " + st.turns +
395
- " · 波段 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 +
396
431
  " · 干预 L1×" + st.l1Count + " L2×" + st.l2Count +
397
- " · 有效率 " + st.effImproved + "/" + st.effSamples + (st.autoMuted ? "(不及抛硬币→已静默)" : "") +
432
+ " · 有效率 " + st.effImproved + "/" + st.effSamples + (st.autoMuted ? "(不及抛硬币→已静默)" : st.effSamples === 0 ? "(样本未满:干预后 3 轮结算)" : st.effSamples < AUTO_MIN ? "(样本 " + st.effSamples + "/" + AUTO_MIN + ")" : "") +
398
433
  " · 上轮 命中" + lastHits + " 发散占比" + (Math.round(lastNegRate * 100) / 100) + "(" + st.lastBand + ")" +
399
434
  " · 采样跳过 " + st.skipped;
400
435
  if (ctx.hasUI) ctx.ui.notify(line, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-think-anchor",
3
- "version": "1.0.3",
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
  }