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.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/lock-core.ts
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
|
132
|
+
if (parsed && parsed.holderPaneId !== ownPaneId && !holders.includes(parsed.holderPaneId)) {
|
|
133
|
+
holders.push(parsed.holderPaneId);
|
|
134
|
+
}
|
|
81
135
|
}
|
|
82
|
-
return
|
|
136
|
+
return holders;
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
/**
|
|
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[];
|
|
91
|
-
| { kind: 'block'; paths: 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
|
|
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
|
|
106
|
-
if (
|
|
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
|
-
|
|
112
|
-
reason: `file locked by
|
|
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
|
-
|
|
119
|
-
warning: formatConflictWarning(norm,
|
|
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
|
-
}
|
package/src/manifest-compose.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
/**
|
|
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
|
-
/**
|
|
13
|
+
/** D82: stance for tools outside the composed set. */
|
|
10
14
|
unknownTools?: UnknownToolStance;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
for (const [
|
|
50
|
-
if (
|
|
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
|
|
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();
|
|
70
|
-
return { tools, permissions, unknownTools:
|
|
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
|
-
):
|
|
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
|
-
*
|
|
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
|
-
|
|
12
|
-
/**
|
|
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
|
-
*
|
|
22
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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++) {
|