pi-pier 0.2.7 → 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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
package/src/lock-core.ts CHANGED
@@ -1,34 +1,31 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D3, M18, S2). */
1
+ /**
2
+ * M18 file-level write locks (S2: soft veto by default, `PI_HERDR_WRITE_LOCK=1` makes it hard).
3
+ *
4
+ * A lock is a *beacon*, not a mutex: the pane editing a file reports a token
5
+ * (`lock-<hash>` → `paneId|path`, see herdr-client.reportLockTokens) and every other pane reads
6
+ * the token table to warn or block. Tokens must match `^[A-Za-z0-9_-]{1,32}$` with ≤16 keys per
7
+ * report, hence the hashed key and the path inside the value.
8
+ */
2
9
  import { resolve, sep } from 'node:path';
3
10
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
11
  export const WRITE_LOCK_ENV = 'PI_HERDR_WRITE_LOCK';
6
- /** Why: Preserve the established compatibility and safety behavior. */
7
12
  export const WRITE_TOOLS = ['write', 'edit'] as const;
8
- /** Why: Preserve the established compatibility and safety behavior. */
9
13
  export const LOCK_TOKEN_PREFIX = 'lock-';
10
- /** Why: Preserve the established compatibility and safety behavior. */
11
14
  export const LOCK_BATCH_LIMIT = 16;
12
- /** Why: Preserve the established compatibility and safety behavior. */
13
15
  export const LOCK_TTL_MS = 60 * 60 * 1000;
14
16
 
15
- /** Why: Preserve the established compatibility and safety behavior. */
16
17
  export interface LockAgentView {
17
18
  paneId: string;
18
19
  tokens: Record<string, string | null>;
19
20
  }
20
21
 
21
- /** Why: Preserve the established compatibility and safety behavior. */
22
-
22
+ /** Case-folded, separator-unified absolute path: the key every lock lookup agrees on. */
23
23
  export function normalizeLockPath(p: string, cwd: string): string {
24
24
  const abs = resolve(cwd, p);
25
25
  const unified = sep === '\\' ? abs.replace(/\\/g, '/') : abs;
26
26
  return unified.toLowerCase().replace(/\/+$/, '');
27
27
  }
28
28
 
29
- /** Why: Preserve the established compatibility and safety behavior. */
30
-
31
- /** Why: Preserve the established compatibility and safety behavior. */
32
29
  export function fnv1a64(s: string): string {
33
30
  let h = 0xcbf29ce484222325n;
34
31
  for (let i = 0; i < s.length; i++) {
@@ -52,43 +49,110 @@ export function parseLockTokenValue(v: string): { holderPaneId: string; path: st
52
49
  return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
53
50
  }
54
51
 
55
- /** Why: Preserve the established compatibility and safety behavior. */
56
52
  export function isLockTokenKey(key: string): boolean {
57
53
  return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
58
54
  }
59
55
 
60
- /** Why: Preserve the established compatibility and safety behavior. */
61
-
62
56
  export function writePathsOfTool(toolName: string, input: unknown): string[] {
63
57
  if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
64
58
  const p = (input as { path?: unknown } | null | undefined)?.path;
65
59
  return typeof p === 'string' && p.trim() ? [p] : [];
66
60
  }
67
61
 
68
- /** Why: Preserve the established compatibility and safety behavior. */
62
+ /**
63
+ * B7: write-locks only see the write/edit tools, so a `bash` command that redirects into a file bypasses
64
+ * the beacon entirely (`> file`, `>> file`, `tee file`, `sed -i`, `truncate -s 0`). We do not try to parse
65
+ * shell: this extracts the *obvious* targets so the lock layer can warn (never block) — a false negative is
66
+ * cheap, a false block is not.
67
+ */
68
+ export function bashWriteTargets(command: unknown): string[] {
69
+ if (typeof command !== 'string' || command.trim() === '') return [];
70
+ const found: string[] = [];
71
+ // Quoted or bare targets; stops at shell metacharacters, ignores process substitution.
72
+ const target = String.raw`(?:'([^']+)'|"([^"]+)"|([^\s;|&<>()]+))`;
73
+ const patterns = [
74
+ new RegExp(String.raw`(?:^|[^0-9>])>>?\s*` + target, 'g'), // > file, >> file
75
+ new RegExp(String.raw`\btee\s+(?:-a\s+)?` + target, 'g'), // tee [-a] file
76
+ new RegExp(String.raw`\btruncate\s+-s\s*0\s+` + target, 'g'), // truncate -s 0 file
77
+ ];
78
+ for (const re of patterns) {
79
+ for (const m of command.matchAll(re)) {
80
+ const path = (m[1] ?? m[2] ?? m[3] ?? '').trim();
81
+ const quoted = m[1] !== undefined || m[2] !== undefined;
82
+ if (isObviousPath(path, quoted)) found.push(path);
83
+ }
84
+ }
85
+ // `sed -i '' 's/a/b/' file`: the first argument is the (possibly empty) backup suffix and the
86
+ // second the expression, so the LAST argument is the file.
87
+ for (const m of command.matchAll(/\bsed\s+-i[^\s]*\s+([^|;&\n]+)/g)) {
88
+ const args = splitShellWords(m[1] ?? '');
89
+ for (let i = args.length - 1; i >= 0; i -= 1) {
90
+ const candidate = args[i]!;
91
+ if (candidate.startsWith('-')) continue;
92
+ if (isObviousPath(candidate)) found.push(candidate);
93
+ break;
94
+ }
95
+ }
96
+ return [...new Set(found)];
97
+ }
98
+
99
+ /** Only unquoted, metacharacter-free paths count as "obvious" (anything else is left to the shell). */
100
+ function isObviousPath(path: string, quoted = false): boolean {
101
+ if (path === '' || path === '/' || path.startsWith('$')) return false;
102
+ if (/['"`$*?{}()[\]]/.test(path)) return false; // Never a literal path (expansion, glob, nested quotes).
103
+ return quoted ? true : !/\s/.test(path); // An explicitly quoted target may contain spaces.
104
+ }
105
+
106
+ /** Whitespace split that keeps quoted segments together and strips their quotes. */
107
+ function splitShellWords(input: string): string[] {
108
+ const out: string[] = [];
109
+ for (const m of input.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
110
+ return out.filter((s) => s !== '');
111
+ }
112
+
113
+ /** Soft hint shown after a bash call that wrote into a path another pane has locked. */
114
+ function formatBashLockHint(normPath: string, holders: readonly string[]): string {
115
+ return `Note: this bash command wrote to ${normPath}, which ${holdersLabel(holders)} is editing. `
116
+ + `Write-locks only cover the write/edit tools, so this write was not blocked: re-read the file before your next edit.`;
117
+ }
69
118
 
70
- export function findLockConflict(
119
+ /** Every *other* pane holding a beacon for this path (deduped, in list order — several panes can hold
120
+ * the same path, so callers get the whole list). */
121
+ export function findLockHolders(
71
122
  agents: readonly LockAgentView[],
72
123
  ownPaneId: string,
73
124
  normPath: string,
74
- ): { holderPaneId: string } | null {
125
+ ): string[] {
75
126
  const key = lockTokenKey(normPath);
127
+ const holders: string[] = [];
76
128
  for (const a of agents) {
77
129
  const v = a.tokens[key];
78
130
  if (typeof v !== 'string' || !v) continue;
79
131
  const parsed = parseLockTokenValue(v);
80
- if (parsed && parsed.holderPaneId !== ownPaneId) return { holderPaneId: parsed.holderPaneId };
132
+ if (parsed && parsed.holderPaneId !== ownPaneId && !holders.includes(parsed.holderPaneId)) {
133
+ holders.push(parsed.holderPaneId);
134
+ }
81
135
  }
82
- return null;
136
+ return holders;
83
137
  }
84
138
 
85
- /** Why: Preserve the established compatibility and safety behavior. */
139
+ /** Where to look for the full holder table: humans use the slash command, agents read herdr tokens. */
140
+ const LOCK_HOLDERS_HINT =
141
+ 'holders: /locks (human view in this pane) · herdr agent list → tokens (agent-readable)';
142
+
143
+ function holdersLabel(holders: readonly string[]): string {
144
+ return holders.length === 1 ? `pane ${holders[0]}` : `panes ${holders.join(', ')}`;
145
+ }
146
+
147
+ function formatConflictWarning(normPath: string, holders: readonly string[]): string {
148
+ return `⚠️ write conflict: ${normPath} is locked by ${holdersLabel(holders)} (edited in another pane that has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes. ${LOCK_HOLDERS_HINT}`;
149
+ }
86
150
 
87
151
  export type WriteGuardPlan =
88
152
  | { kind: 'skip' }
89
153
  | { kind: 'pass'; paths: string[] }
90
- | { kind: 'warn'; paths: string[]; holderPaneId: string; warning: string }
91
- | { kind: 'block'; paths: string[]; holderPaneId: string; reason: string };
154
+ | { kind: 'warn'; paths: string[]; holderPaneIds: string[]; warning: string }
155
+ | { kind: 'block'; paths: string[]; holderPaneIds: string[]; reason: string };
92
156
 
93
157
  export function planWriteGuard(opts: {
94
158
  toolName: string;
@@ -98,32 +162,33 @@ export function planWriteGuard(opts: {
98
162
  cwd: string;
99
163
  hard: boolean;
100
164
  }): WriteGuardPlan {
101
- const raw = writePathsOfTool(opts.toolName, opts.input);
165
+ const isBash = opts.toolName === 'bash';
166
+ const raw = isBash
167
+ ? bashWriteTargets((opts.input as { command?: unknown } | null | undefined)?.command)
168
+ : writePathsOfTool(opts.toolName, opts.input);
102
169
  if (raw.length === 0) return { kind: 'skip' };
103
170
  const paths = raw.map((p) => normalizeLockPath(p, opts.cwd));
104
171
  for (const norm of paths) {
105
- const conflict = findLockConflict(opts.agents, opts.ownPaneId, norm);
106
- if (!conflict) continue;
107
- if (opts.hard) {
172
+ const holders = findLockHolders(opts.agents, opts.ownPaneId, norm);
173
+ if (holders.length === 0) continue;
174
+ if (opts.hard && !isBash) {
108
175
  return {
109
176
  kind: 'block',
110
177
  paths,
111
- holderPaneId: conflict.holderPaneId,
112
- reason: `file locked by pane ${conflict.holderPaneId}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing.`,
178
+ holderPaneIds: holders,
179
+ reason: `file locked by ${holdersLabel(holders)}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing. ${LOCK_HOLDERS_HINT}`,
113
180
  };
114
181
  }
115
182
  return {
116
183
  kind: 'warn',
117
184
  paths,
118
- holderPaneId: conflict.holderPaneId,
119
- warning: formatConflictWarning(norm, conflict.holderPaneId),
185
+ holderPaneIds: holders,
186
+ warning: isBash ? formatBashLockHint(norm, holders) : formatConflictWarning(norm, holders),
120
187
  };
121
188
  }
122
189
  return { kind: 'pass', paths };
123
190
  }
124
191
 
125
- /** Why: Preserve the established compatibility and safety behavior. */
126
-
127
192
  export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
128
193
  const out: Record<string, string> = {};
129
194
  for (const p of normPaths) out[lockTokenKey(p)] = lockTokenValue(p, paneId);
@@ -135,7 +200,3 @@ export function releaseTokensFor(normPaths: readonly string[]): Record<string, n
135
200
  for (const p of normPaths) out[lockTokenKey(p)] = null;
136
201
  return out;
137
202
  }
138
-
139
- export function formatConflictWarning(normPath: string, holderPaneId: string): string {
140
- return `⚠️ write conflict: ${normPath} is locked by pane ${holderPaneId} (it edited this file and has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes.`;
141
- }
@@ -1,21 +1,21 @@
1
- /** Why: Preserve the established compatibility and safety behavior (C7). */
1
+ /**
2
+ * Manifest composition (C7 v2 three-state permissions): role baseline ∪ model suggestion,
3
+ * minus deny rules, with the D82 unknown-tools stance carried through.
4
+ */
2
5
  import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
6
+ import type { RuntimeRoleManifest } from './tool-gate.ts';
3
7
  import { loadRoleConfig } from './role-loader.ts';
4
8
 
5
9
  export interface ManifestSources {
6
10
  roleBaseline: readonly string[];
7
11
  modelSuggested: readonly string[];
8
12
  rulePermissions: Record<string, PermissionAction>;
9
- /** Why: Preserve the established compatibility and safety behavior (D82). */
13
+ /** D82: stance for tools outside the composed set. */
10
14
  unknownTools?: UnknownToolStance;
11
15
  }
12
16
 
13
- export interface ComposedManifest {
14
- tools: string[];
15
- permissions: Record<string, PermissionAction>;
16
- /** Why: Preserve the established compatibility and safety behavior (D82). */
17
- unknownTools: UnknownToolStance;
18
- }
17
+ /** What composeManifest produces: the runtime manifest minus the identity/section fields. */
18
+ export type ComposedManifest = Omit<RuntimeRoleManifest, 'role' | 'version' | 'services' | 'guidelines'>;
19
19
 
20
20
  export class ManifestError extends Error {
21
21
  readonly code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG';
@@ -26,6 +26,11 @@ export class ManifestError extends Error {
26
26
  }
27
27
  }
28
28
 
29
+ export interface ComposedForRole {
30
+ role: RoleManifest;
31
+ manifest: ComposedManifest;
32
+ }
33
+
29
34
  export function composeManifest(sources: ManifestSources): ComposedManifest {
30
35
  const baseline = sources.roleBaseline.filter((t): t is string => typeof t === 'string' && t.trim() !== '');
31
36
  if (baseline.length === 0) {
@@ -40,21 +45,18 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
40
45
  ]);
41
46
  const rules = sources.rulePermissions ?? {};
42
47
  const defaultAction: PermissionAction = rules['*'] ?? 'allow';
43
- const stance: UnknownToolStance = sources.unknownTools ?? 'deny';
44
48
 
45
49
  const tools: string[] = [];
46
50
  const permissions: Record<string, PermissionAction> = {};
47
- // Why: Preserve the established compatibility and safety behavior (D82).
48
- // Why: Preserve the established compatibility and safety behavior.
49
- for (const [k, v] of Object.entries(rules)) {
50
- if (k === '*') continue; // Why: Preserve the established compatibility and safety behavior.
51
- permissions[k] = v;
51
+ // Rule keys outside the candidate set stay in permissions: an allow-stance role keeps its
52
+ // deny rules for tools the model never suggested (D82 excluded families).
53
+ for (const [key, action] of Object.entries(rules)) {
54
+ if (key !== '*') permissions[key] = action;
52
55
  }
53
56
  for (const tool of candidates) {
54
57
  const action = rules[tool] ?? defaultAction;
55
58
  permissions[tool] = action;
56
- if (action === 'deny') continue; // Why: Preserve the established compatibility and safety behavior.
57
- tools.push(tool);
59
+ if (action !== 'deny') tools.push(tool);
58
60
  }
59
61
 
60
62
  if (tools.length === 0) {
@@ -66,16 +68,15 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
66
68
  );
67
69
  }
68
70
 
69
- tools.sort(); // Why: Preserve the established compatibility and safety behavior.
70
- return { tools, permissions, unknownTools: stance };
71
+ tools.sort();
72
+ return { tools, permissions, unknownTools: sources.unknownTools ?? 'deny' };
71
73
  }
72
74
 
73
- /** Why: Preserve the established compatibility and safety behavior (WS-D8). */
74
75
  export function composeForRole(
75
76
  roleName: string,
76
77
  modelSuggested: readonly string[],
77
78
  opts?: { loadRole?: typeof loadRoleConfig; loadRoleOpts?: Parameters<typeof loadRoleConfig>[1] },
78
- ): { role: RoleManifest; manifest: ComposedManifest } {
79
+ ): ComposedForRole {
79
80
  const load = opts?.loadRole ?? loadRoleConfig;
80
81
  const role = load(roleName, opts?.loadRoleOpts);
81
82
  const manifest = composeManifest({
@@ -86,3 +87,17 @@ export function composeForRole(
86
87
  });
87
88
  return { role, manifest };
88
89
  }
90
+
91
+ /** Project a composed profile onto the runtime shape — the one place role-file fields become runtime fields. */
92
+ export function toRuntimeManifest(composed: ComposedForRole): RuntimeRoleManifest {
93
+ const { role, manifest } = composed;
94
+ return {
95
+ role: role.role,
96
+ version: role.version,
97
+ tools: manifest.tools,
98
+ permissions: manifest.permissions,
99
+ unknownTools: manifest.unknownTools,
100
+ services: role.services ?? {},
101
+ ...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
102
+ };
103
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * ObservationPack cores: large-observation detection, deterministic ids, complete-line excerpts,
3
+ * UTF-8-safe chunk slicing, rolling prefix cache economics. Storage I/O is in efficiency-store.ts.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { TOKEN_ACCOUNT_CACHE_RATIO } from './compact-economics-core.ts';
7
+ import { FAILURE_SIGNAL } from './reducer-core.ts';
8
+
9
+ // Fallbacks used only when a caller omits a field; DEFAULT_EFFICIENCY_CONFIG.observationPack is the
10
+ // canonical set (thresholdBytes lives there alone — every pack decision goes through resolved config).
11
+ const DEFAULT_FULL_SENDS = 2;
12
+ const DEFAULT_EXCERPT_BYTES = 1024;
13
+ const CHARS_PER_TOKEN = 4;
14
+
15
+ /** Marker from the evidence-preserving reducer, used to avoid double packing. */
16
+ export const REDUCER_RECEIPT_PREFIX = 'sol_pi_evidence_receipt_v1';
17
+
18
+ export function sha256Hex(val: string | Buffer): string {
19
+ return createHash('sha256').update(val).digest('hex');
20
+ }
21
+
22
+ export function estimateTokens(text: string): number {
23
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
24
+ }
25
+
26
+ export function countLines(text: string): number {
27
+ if (text.length === 0) return 0;
28
+ let lines = text.endsWith('\n') ? 0 : 1;
29
+ for (let i = 0; i < text.length; i++) {
30
+ if (text.charCodeAt(i) === 0x0a) lines++;
31
+ }
32
+ return lines;
33
+ }
34
+
35
+ export function isObservationId(id: string): boolean {
36
+ return /^obs_[a-f0-9]{24}$/.test(id);
37
+ }
38
+
39
+ export function deriveObservationId(toolName: string, toolCallId: string, contentHash: string): string {
40
+ return `obs_${sha256Hex(`${toolName}\0${toolCallId}\0${contentHash}`).slice(0, 24)}`;
41
+ }
42
+
43
+ /** A receipt must be a line of its own — a mere mention inside a log body is not a receipt. */
44
+ export function containsReducerReceipt(text: string): boolean {
45
+ if (!text.includes(REDUCER_RECEIPT_PREFIX)) return false;
46
+ return text.split('\n').some((line) => line.trim() === REDUCER_RECEIPT_PREFIX);
47
+ }
48
+
49
+ /** Whole lines only, from the head or the tail; the scanned window never exceeds budgetBytes * 4 chars. */
50
+ export function completeLineExcerpt(text: string, budgetBytes: number, fromEnd: boolean): string {
51
+ if (budgetBytes <= 0 || text.length === 0) return '';
52
+
53
+ const window = budgetBytes * 4;
54
+ const start = fromEnd ? Math.max(0, text.length - window) : 0;
55
+ const end = fromEnd ? text.length : Math.min(text.length, window);
56
+ const lines = text.slice(start, end).split(/(?<=\n)/);
57
+ const selected: string[] = [];
58
+ let selectedBytes = 0;
59
+ for (let i = fromEnd ? lines.length - 1 : 0; i >= 0 && i < lines.length; i += fromEnd ? -1 : 1) {
60
+ const line = lines[i]!;
61
+ // The head keeps terminated lines only; the tail also keeps the log's final unterminated line.
62
+ if (!fromEnd && !line.endsWith('\n')) break;
63
+ if (fromEnd && i === 0 && start > 0 && text[start - 1] !== '\n') break;
64
+ const lineBytes = Buffer.byteLength(line, 'utf8');
65
+ if (selectedBytes + lineBytes > budgetBytes) break;
66
+ selectedBytes += lineBytes;
67
+ if (fromEnd) selected.unshift(line);
68
+ else selected.push(line);
69
+ }
70
+ return selected.join('');
71
+ }
72
+
73
+ export interface ObservationPlaceholderInput {
74
+ id: string;
75
+ toolName: string;
76
+ bytes: number;
77
+ lines: number;
78
+ tokens: number;
79
+ text: string;
80
+ fullSends?: number;
81
+ excerptBudget?: number;
82
+ /**
83
+ * P0-3 (RFC docs/rfc-jev-integration.md §3): a selected middle window replacing whichever
84
+ * head/tail half carries fewer failure-signal lines. Absent → byte-identical legacy layout.
85
+ */
86
+ middle?: { text: string; label: string };
87
+ }
88
+
89
+ function signalLineCount(text: string): number {
90
+ if (text.length === 0) return 0;
91
+ let count = 0;
92
+ for (const line of text.split('\n')) {
93
+ if (FAILURE_SIGNAL.test(line)) count++;
94
+ }
95
+ return count;
96
+ }
97
+
98
+ export function formatObservationPlaceholder(input: ObservationPlaceholderInput): string {
99
+ const fullSends = input.fullSends ?? DEFAULT_FULL_SENDS;
100
+ const excerptBudget = input.excerptBudget ?? DEFAULT_EXCERPT_BYTES;
101
+ const headBudget = Math.floor(excerptBudget / 2);
102
+ const tailBudget = excerptBudget - headBudget;
103
+
104
+ let head = completeLineExcerpt(input.text, headBudget, false);
105
+ let tail = completeLineExcerpt(input.text, tailBudget, true);
106
+ let headLabel = `[first complete lines, up to ${headBudget} bytes]`;
107
+ let tailLabel = `[middle omitted; last complete lines, up to ${tailBudget} bytes]`;
108
+
109
+ if (input.middle) {
110
+ // Fewer signal lines loses its slot; ties replace head because the tail half usually carries
111
+ // the log's final summary.
112
+ if (signalLineCount(tail) < signalLineCount(head)) {
113
+ tail = completeLineExcerpt(input.middle.text, tailBudget, false);
114
+ tailLabel = `[middle omitted; selected excerpt — ${input.middle.label}, up to ${tailBudget} bytes]`;
115
+ } else {
116
+ head = completeLineExcerpt(input.middle.text, headBudget, false);
117
+ headLabel = `[selected excerpt — ${input.middle.label}, up to ${headBudget} bytes]`;
118
+ }
119
+ }
120
+
121
+ return [
122
+ `[large tool result replaced after its first ${fullSends} provider requests]`,
123
+ `id: ${input.id}`,
124
+ `tool: ${input.toolName}`,
125
+ `original_bytes: ${input.bytes}`,
126
+ `original_lines: ${input.lines}`,
127
+ `estimated_tokens: ${input.tokens}`,
128
+ `retrieve: call obs_recall with {"id":"${input.id}","offset":0}; continue with returned next_offset`,
129
+ headLabel,
130
+ head,
131
+ tailLabel,
132
+ tail,
133
+ `[${input.bytes} original bytes omitted; recall via obs_recall]`,
134
+ ].join('\n');
135
+ }
136
+
137
+ export interface RecallSliceResult {
138
+ text: string;
139
+ bytes: number;
140
+ lines: number;
141
+ nextOffset: number;
142
+ eof: boolean;
143
+ }
144
+
145
+ function trimUtf8End(buffer: Buffer, limit: number): number {
146
+ let end = limit;
147
+ while (end > 0 && end < buffer.length && ((buffer[end] ?? 0) & 0xc0) === 0x80) {
148
+ end -= 1;
149
+ }
150
+ return end;
151
+ }
152
+
153
+ /** Byte offsets, never cutting a UTF-8 character. */
154
+ export function sliceBufferChunk(
155
+ buf: Buffer,
156
+ offset: number,
157
+ limits: { maxBytes: number; maxLines: number },
158
+ ): RecallSliceResult {
159
+ if (offset >= buf.length) {
160
+ return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
161
+ }
162
+
163
+ let end = Math.min(buf.length - offset, limits.maxBytes);
164
+ let newlineCount = 0;
165
+ for (let i = 0; i < end; i++) {
166
+ if (buf[offset + i] === 0x0a && ++newlineCount === limits.maxLines) {
167
+ end = i + 1;
168
+ break;
169
+ }
170
+ }
171
+
172
+ const chunkBuf = buf.subarray(offset, offset + trimUtf8End(buf.subarray(offset), end));
173
+ const text = chunkBuf.toString('utf8');
174
+ const nextOffset = offset + chunkBuf.length;
175
+ return {
176
+ text,
177
+ bytes: chunkBuf.length,
178
+ lines: countLines(text),
179
+ nextOffset,
180
+ eof: nextOffset >= buf.length,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * `cacheWriteReadRatio` must already be resolved by the caller via `resolveCacheRatioFromCost` (the
186
+ * same value OCC uses); a raw `'auto'` would silently diverge from OCC's resolution.
187
+ */
188
+ export function shouldPackForCache(opts: {
189
+ removedTokens: number;
190
+ tailTokensAfter: number;
191
+ expectedRemainingRequests: number;
192
+ cacheWriteReadRatio: number | null;
193
+ }): boolean {
194
+ if (opts.removedTokens <= 0) return false;
195
+ const ratio = opts.cacheWriteReadRatio ?? TOKEN_ACCOUNT_CACHE_RATIO;
196
+ if (ratio <= 1.0) return true; // No incremental write cost over reads.
197
+
198
+ const remaining = Math.max(1, opts.expectedRemainingRequests);
199
+ return opts.removedTokens * remaining > opts.tailTokensAfter * (ratio - 1.0);
200
+ }
package/src/pane-title.ts CHANGED
@@ -1,39 +1,24 @@
1
- /**
2
- * Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68).
3
- *
4
- * The pi session JSONL remains authoritative; this layer only condenses the snapshot into a title and blocked badge.
5
- * herdr truncates title/state_label to 80 characters, so truncate locally to avoid cutting an emoji in half.
6
- */
1
+ /** Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68); the pi session
2
+ * JSONL stays authoritative and herdr truncates title/state_label at 80 chars, so truncate locally
3
+ * to avoid cutting an emoji in half. */
7
4
  import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
8
5
  import { formatAge, isArchived } from './stale-core.ts';
9
6
 
10
7
  /** herdr's character limit for title/state_label (from official documentation and schema measurements). */
11
- export const TITLE_MAX = 80;
12
- /** Empty list: callers send clear_title rather than an empty string. */
13
- export const TITLE_EMPTY = null;
14
- /**
15
- * Valid state_labels keys are only idle|working|blocked|done|unknown
16
- * (herdr socket-api; the early WIRE `todo` value was rejected as invalid_state_label).
17
- */
8
+ const TITLE_MAX = 80;
9
+ /** Valid state_labels keys are only idle|working|blocked|done|unknown (herdr socket-api). */
18
10
  export const BLOCKED_LABEL_KEY = 'blocked';
19
11
 
20
- /**
21
- * D93: custom sidebar token for the todo summary (referenced as `$pi-todo` by herdr `[ui.sidebar.agents]`).
22
- * Keep its value in the same format and source as the pane title (`▶i ○p ■b ✓c (N/M) · activity`).
23
- */
24
- export const SIDEBAR_TODO_TOKEN = 'pi-todo';
12
+ /** D93: custom sidebar token for the todo summary, referenced as `$pi-todo` by herdr
13
+ * `[ui.sidebar.agents]`; its value keeps the pane-title format (`▶i ○p ■b ✓c (N/M) · activity`). */
14
+ const SIDEBAR_TODO_TOKEN = 'pi-todo';
25
15
  /** D95: token marking an ask_user_question wait (the workbench heat scale uses it to distinguish ask from block). */
26
16
  export const SIDEBAR_ASK_TOKEN = 'pi-ask';
27
17
 
28
- /**
29
- * D93: build the pi-todo token patch for report_metadata.
30
- * A title becomes the token value so the sidebar can render `$pi-todo`; no todo becomes an empty string so herdr's
31
- * patch semantics remove the key rather than retaining an old summary.
32
- *
33
- * D96 correction: **do not merge stale**. Stale (16 nulls) is a one-time cleanup sent separately via reportMetadata;
34
- * merging would make 17 entries exceed herdr's tokens maxProperties=16, rejecting the entire request and dropping
35
- * both title and tokens (observed on the user's machine: pane title and sidebar token disappeared; D93 regression root cause).
36
- */
18
+ /** D93: build the pi-todo token patch for report_metadata (no todo = an empty string, because herdr's
19
+ * patch semantics remove empty keys). D96: never merge the stale cleanup in here — stale(16) +
20
+ * pi-todo(1) exceeds herdr's tokens maxProperties=16 and the rejected request would drop both the
21
+ * title and the tokens. */
37
22
  export function sidebarTodoTokens(title: string | null): Record<string, string> {
38
23
  return { [SIDEBAR_TODO_TOKEN]: title ?? '' };
39
24
  }
@@ -45,20 +30,18 @@ function clipTitle(s: string): string {
45
30
  }
46
31
 
47
32
  /**
48
- * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four black-and-white status glyphs).
49
- * activity is the first in_progress item, then fallbackDescription, then only the counts.
50
- * Empty list → null. progressSuffix (M16) is produced by formatProgressSuffix.
51
- *
52
- * Anti-freeze behavior (stale-core D): once all work is complete and the wall clock expires (archived), lower the
53
- * dead list to `✓N done <age>` instead of presenting it as a fully weighted current-state view; report_agent's tool
54
- * badge/activity line explains what is happening, while the todo token reports only the plan's actual state.
33
+ * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four status glyphs). activity is the
34
+ * first in_progress item, then fallbackDescription, then only the counts. Empty list → null, which
35
+ * callers translate into clear_title; progressSuffix (M16) comes from formatProgressSuffix.
36
+ * Anti-freeze (stale-core D): once all work completed and the wall clock expired (archived), the
37
+ * dead list degrades to `✓N done <age>` instead of presenting itself as a current-state view.
55
38
  */
56
39
  export function formatPaneTitle(
57
40
  items: readonly TodoItem[],
58
41
  fallbackDescription?: string | null,
59
42
  opts?: { progressSuffix?: string | null; lastWriteAt?: number | null; now?: number },
60
43
  ): string | null {
61
- if (items.length === 0) return TITLE_EMPTY;
44
+ if (items.length === 0) return null;
62
45
  const c = countTodos(items);
63
46
  const now = opts?.now ?? Date.now();
64
47
  if (isArchived(items, opts?.lastWriteAt ?? null, now)) {
@@ -82,9 +65,7 @@ export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
82
65
  return clipTitle(text);
83
66
  }
84
67
 
85
- /**
86
- * The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls remain—M13b).
87
- */
68
+ /** The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls would otherwise remain — M13b). */
88
69
  export function staleTokenClearance(): Record<string, null> {
89
70
  const tokens: Record<string, null> = { [PI_HERDR_META_KEY]: null };
90
71
  for (let i = 0; i < STALE_CHUNK_COUNT; i++) {