@trim21/personal-pi-extensions 0.1.512 → 0.1.513

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/aft/tools.ts +99 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.512",
3
+ "version": "0.1.513",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/tools.ts CHANGED
@@ -444,18 +444,20 @@ const semanticIndexProgressSchema = Type.Object({
444
444
 
445
445
  type SemanticIndexProgress = Static<typeof semanticIndexProgressSchema>;
446
446
 
447
- /** status 快照 → 一行构建进度文本;非 Building 或形状不符返回 undefined(不显示)。 */
448
- export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string | undefined {
447
+ /** 解析快照里的语义索引构建进度;非 Building 或形状不符返回 undefined(不显示)。 */
448
+ function parseSemanticIndexProgress(snapshot: StatusSnapshot): SemanticIndexProgress | undefined {
449
449
  const raw = snapshot.semantic_index;
450
450
  if (raw === undefined) {
451
451
  return undefined;
452
452
  }
453
- let progress: SemanticIndexProgress;
454
453
  try {
455
- progress = Value.Parse(semanticIndexProgressSchema, raw);
454
+ return Value.Parse(semanticIndexProgressSchema, raw);
456
455
  } catch {
457
456
  return undefined;
458
457
  }
458
+ }
459
+
460
+ function semanticIndexProgressParts(progress: SemanticIndexProgress): string[] {
459
461
  const parts = [`语义索引构建中${progress.stage === undefined ? "" : ` (${progress.stage})`}`];
460
462
  if (
461
463
  progress.embedded_chunks !== undefined &&
@@ -475,7 +477,97 @@ export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string |
475
477
  ) {
476
478
  parts.push(`batch ${progress.current_batch}/${progress.total_batches}`);
477
479
  }
478
- return parts.join(" · ");
480
+ return parts;
481
+ }
482
+
483
+ /** status 快照 → 一行构建进度文本(不含 ETA)。 */
484
+ export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string | undefined {
485
+ const progress = parseSemanticIndexProgress(snapshot);
486
+ if (progress === undefined) {
487
+ return undefined;
488
+ }
489
+ return semanticIndexProgressParts(progress).join(" · ");
490
+ }
491
+
492
+ /** eta 速率估计的滑动窗口:只看最近这段时间的样本,丢弃更早的。 */
493
+ const ETA_WINDOW_MS = 15_000;
494
+ /** 样本跨度过短时速率噪声太大(快照约每秒一条),不显示 ETA。 */
495
+ const ETA_MIN_WINDOW_MS = 3_000;
496
+ /** 滑动窗口里最多保留的样本数,防快照异常密集时无界增长。 */
497
+ const ETA_MAX_SAMPLES = 30;
498
+
499
+ interface EtaSample {
500
+ readonly at: number;
501
+ readonly embedded: number;
502
+ readonly stage: string | undefined;
503
+ }
504
+
505
+ function formatEta(ms: number): string {
506
+ // 取整到 5s 粒度,避免给出虚假的精确感。
507
+ const seconds = Math.max(5, Math.round(ms / 1000 / 5) * 5);
508
+ if (seconds < 60) {
509
+ return `${seconds}s`;
510
+ }
511
+ const minutes = Math.floor(seconds / 60);
512
+ const rest = seconds % 60;
513
+ return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`;
514
+ }
515
+
516
+ /**
517
+ * 带剩余时间估计的进度格式化器。基于快照序列做滑动窗口速率外推
518
+ * (embedding 批次速率受远端 API 波动影响并非线性,窗口越近越准)。
519
+ * total_chunks / total_batches 变化只影响分母,批次处理速率不变,
520
+ * 直接用新 total 重算 ETA,不清空样本;仅 stage 变化(换工作内容)或
521
+ * embedded 回退(watcher 重建)时重置。工厂 + 闭包持有状态:每次
522
+ * 工具调用 / CLI 运行创建独立实例。
523
+ */
524
+ export function createSemanticIndexProgressFormatter(options?: {
525
+ now?: () => number;
526
+ }): (snapshot: StatusSnapshot) => string | undefined {
527
+ const now = options?.now ?? Date.now;
528
+ let samples: EtaSample[] = [];
529
+
530
+ return (snapshot: StatusSnapshot): string | undefined => {
531
+ const progress = parseSemanticIndexProgress(snapshot);
532
+ if (progress === undefined) {
533
+ return undefined;
534
+ }
535
+ const parts = semanticIndexProgressParts(progress);
536
+
537
+ const { embedded_chunks: embedded, total_chunks: total, stage } = progress;
538
+ const last = samples.at(-1);
539
+ if (last !== undefined && last.stage !== stage) {
540
+ samples = [];
541
+ }
542
+ if (embedded !== undefined && total !== undefined && total > 0) {
543
+ if (last !== undefined && embedded < last.embedded) {
544
+ samples = [];
545
+ }
546
+ const at = now();
547
+ samples.push({ at, embedded, stage });
548
+ samples = samples.filter((sample) => at - sample.at <= ETA_WINDOW_MS);
549
+ if (samples.length > ETA_MAX_SAMPLES) {
550
+ samples = samples.slice(-ETA_MAX_SAMPLES);
551
+ }
552
+ }
553
+
554
+ const first = samples.at(0);
555
+ const current = samples.at(-1);
556
+ if (
557
+ first !== undefined &&
558
+ current !== undefined &&
559
+ total !== undefined &&
560
+ first !== current &&
561
+ current.at - first.at >= ETA_MIN_WINDOW_MS
562
+ ) {
563
+ const rate = (current.embedded - first.embedded) / (current.at - first.at);
564
+ const remaining = total - current.embedded;
565
+ if (rate > 0 && remaining > 0) {
566
+ parts.push(`剩余约 ${formatEta(remaining / rate)}`);
567
+ }
568
+ }
569
+ return parts.join(" · ");
570
+ };
479
571
  }
480
572
 
481
573
  const SearchParams = Type.Object(
@@ -520,11 +612,12 @@ export function registerSearchTool(pi: ExtensionAPI, ctx: AftToolContext): void
520
612
  });
521
613
 
522
614
  const bridge = bridgeFor(ctx);
615
+ const formatProgress = createSemanticIndexProgressFormatter();
523
616
  const stopProgress =
524
617
  onUpdate === undefined
525
618
  ? undefined
526
619
  : subscribeBridgeStatus(bridge, (snapshot) => {
527
- const text = formatSemanticIndexProgress(snapshot);
620
+ const text = formatProgress(snapshot);
528
621
  if (text !== undefined) {
529
622
  onUpdate({ content: [{ type: "text", text }], details: undefined });
530
623
  }