pi-pier 0.3.1 → 0.4.2
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 +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed set of cordis services this process provides on the root.
|
|
3
|
+
*
|
|
4
|
+
* `ctx.get`/`ctx.provide` also accept an arbitrary string and return `any`, so a raw call plus `as`
|
|
5
|
+
* neither rejects a mistyped key nor a dropped bag field (fe99252). Mounts and plugins go through
|
|
6
|
+
* the helpers below. Instances stay on the root: a plugin-fiber Service would unload them on HMR.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
+
import type { DisposeLedger } from './ledger.ts';
|
|
10
|
+
import type { PiSurface } from './pi-surface.ts';
|
|
11
|
+
import type { TerminalDeps } from './plugins/terminal.ts';
|
|
12
|
+
import type { TodoDeps, TodoUiSlot } from './plugins/todo.ts';
|
|
13
|
+
import type { TodosService } from './todos-service.ts';
|
|
14
|
+
import type { SubagentDeps } from './plugins/subagent.ts';
|
|
15
|
+
|
|
16
|
+
export interface PierCordisServices {
|
|
17
|
+
'pi-herdr.ledger': DisposeLedger;
|
|
18
|
+
'pi-herdr.surface': PiSurface<object>;
|
|
19
|
+
'pi-herdr.terminal-deps': TerminalDeps;
|
|
20
|
+
'pi-herdr.todo-deps': TodoDeps;
|
|
21
|
+
'pi-herdr.subagent-deps': SubagentDeps;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare module '@deepseek-ai/cordis' {
|
|
25
|
+
interface Context extends PierCordisServices {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cordisProvide<K extends keyof PierCordisServices>(
|
|
29
|
+
ctx: Context,
|
|
30
|
+
key: K,
|
|
31
|
+
value: PierCordisServices[K],
|
|
32
|
+
): void {
|
|
33
|
+
ctx.provide(key, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Throws when the root has not provided `key`. `ctx.get` itself returns `T | undefined`. */
|
|
37
|
+
export function cordisGet<K extends keyof PierCordisServices>(ctx: Context, key: K): PierCordisServices[K] {
|
|
38
|
+
const value = ctx.get(key) as PierCordisServices[K] | undefined;
|
|
39
|
+
if (value === undefined) throw new Error(`[pi-herdr] cordis service missing: ${key}`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The six required 'pi-herdr.todo-deps' fields both mounts assemble identically (maxItems mirrors
|
|
44
|
+
* the tool prompt's "at most 15 entries"); masters and workers spread their own optional seams —
|
|
45
|
+
* getWorkers/getBlockedDepth/stopReminder — on top of this base. */
|
|
46
|
+
export function baseTodoDeps(m: {
|
|
47
|
+
pi: unknown;
|
|
48
|
+
todos: TodosService;
|
|
49
|
+
todoUi: TodoUiSlot;
|
|
50
|
+
mirrorTodos: () => void;
|
|
51
|
+
}): Pick<TodoDeps, 'todos' | 'allowParallelInProgress' | 'maxItems' | 'mirrorTodos' | 'appendEntry' | 'state'> {
|
|
52
|
+
return {
|
|
53
|
+
todos: m.todos,
|
|
54
|
+
allowParallelInProgress: m.todos.config.allowParallelInProgress,
|
|
55
|
+
maxItems: 15,
|
|
56
|
+
mirrorTodos: m.mirrorTodos,
|
|
57
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
58
|
+
// Unchecked cast: pi's ExtensionAPI type omits this optional runtime entry-append seam.
|
|
59
|
+
const pi = m.pi as { appendEntry?: (t: string, d: unknown) => void };
|
|
60
|
+
pi.appendEntry?.(customType, data);
|
|
61
|
+
},
|
|
62
|
+
state: m.todoUi,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/dashboard-command.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
|
|
2
|
+
import { openPierPanel } from './panel-open.ts';
|
|
2
3
|
import { isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
|
|
3
4
|
|
|
4
5
|
export interface DashboardCommandDeps {
|
|
@@ -9,6 +10,8 @@ export interface DashboardCommandDeps {
|
|
|
9
10
|
env: HerdrEnv | null;
|
|
10
11
|
getTodoItems?: () => readonly TodoItem[];
|
|
11
12
|
getHeldLocks?: () => readonly string[];
|
|
13
|
+
/** Publishes the current delegation tree and returns its snapshot path (panel Tree tab). */
|
|
14
|
+
prepareTree?: () => string | null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/** SUMMARY_ORDER fixes the summary-line order; LIVE_ROWS adds the detail line below it.
|
|
@@ -74,26 +77,48 @@ export function formatStandaloneDashboard(opts: {
|
|
|
74
77
|
|
|
75
78
|
export function installDashboardCommand(deps: DashboardCommandDeps): void {
|
|
76
79
|
deps.pi.registerCommand('dashboard', {
|
|
77
|
-
description: 'Open the Pier Ops Dashboard (modal popup
|
|
78
|
-
handler: async (
|
|
80
|
+
description: 'Open the Pier Ops Dashboard (modal popup by default; `/dashboard overlay` = independent full-window pane)',
|
|
81
|
+
handler: async (args, ctx) => {
|
|
79
82
|
const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
83
|
+
const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
|
|
80
84
|
|
|
81
85
|
if (deps.client.available && deps.env) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
if (overlay) {
|
|
87
|
+
// Escape hatch: a real full-window pane instead of the singleton popup.
|
|
88
|
+
const treeFile = deps.prepareTree?.() ?? null;
|
|
89
|
+
try {
|
|
90
|
+
const res = await deps.client.openPluginPane({
|
|
91
|
+
pluginId: 'pier.workbench',
|
|
92
|
+
entrypoint: 'dashboard',
|
|
93
|
+
placement: 'overlay',
|
|
94
|
+
focus: true,
|
|
95
|
+
env: {
|
|
96
|
+
PIER_PANEL: 'dashboard',
|
|
97
|
+
...(treeFile ? { PIER_TREE_FILE: treeFile } : {}),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
if (res.mode === 'fallback_tab') {
|
|
101
|
+
ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
} catch {
|
|
105
|
+
// Fall through to the local view.
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const res = await openPierPanel(
|
|
109
|
+
{ client: deps.client, env: deps.env },
|
|
110
|
+
{ panel: 'dashboard', treeFile: deps.prepareTree?.() ?? null },
|
|
111
|
+
);
|
|
112
|
+
if (res === 'popup') return;
|
|
113
|
+
if (res === 'tab') {
|
|
92
114
|
ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (res === 'busy') {
|
|
118
|
+
ui?.notify?.('Another popup is already open — close it and run /dashboard again.', 'warning');
|
|
119
|
+
return;
|
|
93
120
|
}
|
|
94
|
-
|
|
95
|
-
} catch {
|
|
96
|
-
// Plugin missing or socket error: fall through to the Level 3 local view.
|
|
121
|
+
// 'unavailable' → Level 3 local view.
|
|
97
122
|
}
|
|
98
123
|
}
|
|
99
124
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation identity (RFC docs/rfc-delegation-identity.md Phase 1): a worker pane knows who
|
|
3
|
+
* delegated it, for what task, and shows that on every surface it already touches — system prompt
|
|
4
|
+
* section, session custom entry (first-frame card), herdr sidebar tokens, and the pane title.
|
|
5
|
+
* Pure builders only; the composition root owns the side effects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Contract env handed to the worker process by the master (PI_HERDR_ prefix per naming rule). */
|
|
9
|
+
export const DELEGATION_ENV = 'PI_HERDR_DELEGATION';
|
|
10
|
+
/** Worker-session custom entry written once at first boot; replayed on resume. */
|
|
11
|
+
export const DELEGATION_CUSTOM_TYPE = 'pi-herdr.delegation';
|
|
12
|
+
/** herdr sidebar tokens (pane.report_metadata): parent pane + delegated task. */
|
|
13
|
+
export const DELEGATION_PARENT_TOKEN = 'pi-parent';
|
|
14
|
+
export const DELEGATION_TASK_TOKEN = 'pi-task';
|
|
15
|
+
/** herdr truncates title/token values at 80 chars; clip locally to keep glyphs whole. */
|
|
16
|
+
const TOKEN_MAX = 80;
|
|
17
|
+
|
|
18
|
+
export interface DelegationContext {
|
|
19
|
+
/** Master pane that spawned this worker; '' when the master pane is unknown (tests, RPC). */
|
|
20
|
+
readonly parentPaneId: string;
|
|
21
|
+
/** Master pi session id (cross-locate the parent transcript via /resume). */
|
|
22
|
+
readonly parentSessionId: string;
|
|
23
|
+
/** SubEntry.taskId — stable across revives. */
|
|
24
|
+
readonly taskId: string;
|
|
25
|
+
/** Task short label (SubEntry.description). */
|
|
26
|
+
readonly description: string;
|
|
27
|
+
/** Role manifest name active at spawn. */
|
|
28
|
+
readonly role: string;
|
|
29
|
+
/** The master's pipe name — the worker's `todo_sync` push dials it directly (D49 reply-address
|
|
30
|
+
* semantics, precomputed because the worker cannot derive the master's session cwd). */
|
|
31
|
+
readonly parentPipe?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function str(v: unknown): string | null {
|
|
35
|
+
return typeof v === 'string' ? v : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validate a parsed delegation record (env JSON, marker JSON or custom-entry data); null when malformed. */
|
|
39
|
+
export function delegationFromRecord(parsed: unknown): DelegationContext | null {
|
|
40
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
41
|
+
const rec = parsed as Record<string, unknown>; // shape-checked field by field below
|
|
42
|
+
const parentPipe = str(rec.parentPipe);
|
|
43
|
+
const taskId = str(rec.taskId);
|
|
44
|
+
const description = str(rec.description);
|
|
45
|
+
if (!taskId || !description) return null;
|
|
46
|
+
return {
|
|
47
|
+
parentPaneId: str(rec.parentPaneId) ?? '',
|
|
48
|
+
parentSessionId: str(rec.parentSessionId) ?? '',
|
|
49
|
+
taskId,
|
|
50
|
+
description,
|
|
51
|
+
role: str(rec.role) ?? 'worker-default',
|
|
52
|
+
...(parentPipe ? { parentPipe } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse the spawn env; null when absent or structurally invalid (taskId/description mandatory). */
|
|
57
|
+
export function parseDelegationContext(raw: string | undefined | null): DelegationContext | null {
|
|
58
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
59
|
+
try {
|
|
60
|
+
return delegationFromRecord(JSON.parse(raw));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** env map for spawnPaneInTaskTab/createTab; JSON like the role manifest next to it. */
|
|
67
|
+
export function delegationEnv(ctx: DelegationContext): Record<string, string> {
|
|
68
|
+
return { [DELEGATION_ENV]: JSON.stringify(ctx) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fold `pi-herdr.delegation` branch entries (last one wins) — the /resume replay source. */
|
|
72
|
+
export function foldDelegation(
|
|
73
|
+
entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>,
|
|
74
|
+
): DelegationContext | null {
|
|
75
|
+
let found: DelegationContext | null = null;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.type !== 'custom' || entry.customType !== DELEGATION_CUSTOM_TYPE) continue;
|
|
78
|
+
const next = delegationFromRecord(entry.data);
|
|
79
|
+
if (next) found = next;
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* System-prompt section text: identity + delivery contract in one stable wording, so the master
|
|
86
|
+
* model does not have to restate it in every spawn prompt (pi-subagent-manager idea A+F).
|
|
87
|
+
*/
|
|
88
|
+
export function buildDelegationSection(ctx: DelegationContext, ownPaneId: string): string {
|
|
89
|
+
const parent = ctx.parentPaneId || 'unknown pane';
|
|
90
|
+
const session = ctx.parentSessionId || 'unknown session';
|
|
91
|
+
const self = ownPaneId ? ` You are pane ${ownPaneId} (agentId "${ownPaneId}") — cite it when asking questions or reporting errors.`
|
|
92
|
+
: '';
|
|
93
|
+
return [
|
|
94
|
+
`You are a delegated subagent of the master pane ${parent} (session ${session}); you were not started by a human.`,
|
|
95
|
+
`Task: ${ctx.description}. Role: ${ctx.role}.${self}`,
|
|
96
|
+
'Contract: when finished, state your final answer plainly — the master collects it from this pane\'s settlement; do not ask who requested the work. If you cannot finish, say exactly what blocks you instead of stopping silently.',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sidebar token patch; empty (no request) when there is no parent pane to attribute. */
|
|
101
|
+
export function delegationSidebarTokens(ctx: DelegationContext): Record<string, string> {
|
|
102
|
+
if (!ctx.parentPaneId) return {};
|
|
103
|
+
return {
|
|
104
|
+
[DELEGATION_PARENT_TOKEN]: ctx.parentPaneId.slice(0, TOKEN_MAX),
|
|
105
|
+
[DELEGATION_TASK_TOKEN]: ctx.description.slice(0, TOKEN_MAX),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Worker pane title marker: `↳ <todo title>`, or `↳ <task>` alone before the first todo_write. */
|
|
110
|
+
export function applyWorkerTag(title: string | null, delegatedTask: string | null): string | null {
|
|
111
|
+
if (!delegatedTask) return title;
|
|
112
|
+
const base = title ?? delegatedTask;
|
|
113
|
+
return `↳ ${base.slice(0, TOKEN_MAX - 2)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
* top-level key disables every mechanism).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isPlainObject } from './guards.ts';
|
|
12
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
|
|
17
|
+
/** pi's thinking levels in ascending order, plus `inherit` (summarize at the session's own level). */
|
|
18
|
+
export const SUMMARY_THINKING_LEVELS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
19
|
+
export type SummaryThinkingLevel = (typeof SUMMARY_THINKING_LEVELS)[number];
|
|
20
|
+
|
|
16
21
|
export interface OnlineContextCompactConfig {
|
|
17
22
|
enabled: boolean;
|
|
18
23
|
logEnabled: boolean;
|
|
@@ -20,6 +25,8 @@ export interface OnlineContextCompactConfig {
|
|
|
20
25
|
keepRecentTokens?: number;
|
|
21
26
|
firstCompactionRequestScale: number;
|
|
22
27
|
subsequentCompactionMargin: number;
|
|
28
|
+
/** Cap on the summary request's thinking level (never raised above the session's). */
|
|
29
|
+
summaryThinkingLevel: SummaryThinkingLevel;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export interface ObservationPackConfig {
|
|
@@ -73,7 +80,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
|
|
|
73
80
|
version: 1,
|
|
74
81
|
onlineContextCompact: Object.freeze({
|
|
75
82
|
enabled: false, logEnabled: false, cacheWriteReadRatio: 'auto', keepRecentTokens: 20000,
|
|
76
|
-
firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5,
|
|
83
|
+
firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5, summaryThinkingLevel: 'low',
|
|
77
84
|
}),
|
|
78
85
|
observationPack: Object.freeze({
|
|
79
86
|
enabled: false, logEnabled: false, thresholdBytes: 10240, fullSends: 2,
|
|
@@ -86,11 +93,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
|
|
|
86
93
|
jev: Object.freeze({ enabled: false, logEnabled: false, model: 'jev-1.13.0', timeoutMs: 2000, minConfidence: 0.6 }),
|
|
87
94
|
});
|
|
88
95
|
|
|
89
|
-
|
|
90
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString';
|
|
96
|
+
type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString' | 'enum';
|
|
94
97
|
|
|
95
98
|
interface FieldSpec {
|
|
96
99
|
readonly key: string;
|
|
@@ -100,6 +103,8 @@ interface FieldSpec {
|
|
|
100
103
|
readonly max?: number;
|
|
101
104
|
/** Issue text for a bound that must not be printed as a plain JS number ("1.0"). */
|
|
102
105
|
readonly minText?: string;
|
|
106
|
+
/** Accepted values for `enum`. */
|
|
107
|
+
readonly values?: readonly string[];
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** A section is `{enabled, ...fields}`; field order fixes the order of reported issues. */
|
|
@@ -116,6 +121,7 @@ const SECTIONS: readonly SectionSpec[] = [
|
|
|
116
121
|
{ key: 'keepRecentTokens', kind: 'int', min: 1000 },
|
|
117
122
|
{ key: 'firstCompactionRequestScale', kind: 'number', min: 1, minText: '1.0' },
|
|
118
123
|
{ key: 'subsequentCompactionMargin', kind: 'number', min: 1, minText: '1.0' },
|
|
124
|
+
{ key: 'summaryThinkingLevel', kind: 'enum', values: SUMMARY_THINKING_LEVELS },
|
|
119
125
|
] },
|
|
120
126
|
{ name: 'observationPack', fields: [
|
|
121
127
|
{ key: 'enabled', kind: 'bool' },
|
|
@@ -159,6 +165,8 @@ function issueFor(section: string, field: FieldSpec): string {
|
|
|
159
165
|
: `${path} 必须是 ${field.min} 到 ${field.max} 之间的数字`;
|
|
160
166
|
case 'ratio':
|
|
161
167
|
return `${path} 必须是 "auto" 或非负有限数`;
|
|
168
|
+
case 'enum':
|
|
169
|
+
return `${path} 必须是 ${(field.values ?? []).join(' | ')} 之一`;
|
|
162
170
|
default:
|
|
163
171
|
return `${path} 必须是非空字符串`;
|
|
164
172
|
}
|
|
@@ -193,6 +201,9 @@ function applyField(target: Record<string, unknown>, field: FieldSpec, raw: unkn
|
|
|
193
201
|
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
194
202
|
target[field.key] = raw.trim();
|
|
195
203
|
return true;
|
|
204
|
+
case 'enum':
|
|
205
|
+
if (typeof raw !== 'string' || !(field.values ?? []).includes(raw)) return false;
|
|
206
|
+
break;
|
|
196
207
|
}
|
|
197
208
|
target[field.key] = raw;
|
|
198
209
|
return true;
|
package/src/efficiency-store.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { constants } from 'node:fs';
|
|
8
|
-
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
8
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, type FileHandle } from 'node:fs/promises';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
11
|
import {
|
|
@@ -52,13 +52,28 @@ export function observationObjectPath(root: string, obsId: string): string {
|
|
|
52
52
|
return join(root, 'observation-pack', 'objects', `${obsId}.txt`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Cross-process idempotence probe: a resumed process has no memo but the content-addressed object
|
|
56
|
+
* is still on disk. A wrong-sized file is treated as absent so the writer re-verifies it. */
|
|
57
|
+
export async function observationObjectExists(
|
|
58
|
+
root: string,
|
|
59
|
+
obsId: string,
|
|
60
|
+
expectedBytes?: number,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
const stats = await lstat(observationObjectPath(root, obsId));
|
|
64
|
+
return stats.isFile() && (expectedBytes === undefined || stats.size === expectedBytes);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
export function reducerObjectPath(root: string, sha256: string): string {
|
|
56
71
|
return join(root, 'evidence-preserving-reducer', 'objects', `${sha256}.txt`);
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
export function efficiencyLogPath(
|
|
60
75
|
root: string,
|
|
61
|
-
mechanism: 'compact' | 'observation' | 'reducer' | 'jev' | 'routing',
|
|
76
|
+
mechanism: 'compact' | 'observation' | 'reducer' | 'jev' | 'routing' | 'tools',
|
|
62
77
|
customPath?: string,
|
|
63
78
|
): string {
|
|
64
79
|
if (customPath && typeof customPath === 'string') return resolve(customPath);
|
|
@@ -202,6 +217,27 @@ export async function storeContentAddressedObject(
|
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
const ALIGN_SCAN_BYTES = 64 * 1024;
|
|
221
|
+
|
|
222
|
+
/** Advance a mid-line offset to the next line start so a recall page never opens inside a line.
|
|
223
|
+
* A single-line tail (no newline left) keeps the requested offset: skipping to EOF would make the
|
|
224
|
+
* rest of the object unreadable. */
|
|
225
|
+
async function alignOffsetToLineStart(handle: FileHandle, offset: number, size: number): Promise<number> {
|
|
226
|
+
if (offset <= 0 || offset >= size) return offset;
|
|
227
|
+
const prev = Buffer.alloc(1);
|
|
228
|
+
const { bytesRead: prevRead } = await handle.read(prev, 0, 1, offset - 1);
|
|
229
|
+
if (prevRead === 0 || prev[0] === 0x0a) return offset;
|
|
230
|
+
|
|
231
|
+
const scan = Buffer.alloc(Math.min(ALIGN_SCAN_BYTES, size - offset));
|
|
232
|
+
for (let pos = offset; pos < size; pos += scan.length) {
|
|
233
|
+
const { bytesRead } = await handle.read(scan, 0, Math.min(scan.length, size - pos), pos);
|
|
234
|
+
if (bytesRead === 0) break;
|
|
235
|
+
const newline = scan.subarray(0, bytesRead).indexOf(0x0a);
|
|
236
|
+
if (newline !== -1) return pos + newline + 1;
|
|
237
|
+
}
|
|
238
|
+
return offset;
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
export async function readStoredObjectChunk(
|
|
206
242
|
filePath: string,
|
|
207
243
|
offset: number,
|
|
@@ -218,14 +254,15 @@ export async function readStoredObjectChunk(
|
|
|
218
254
|
throw new Error(`Offset ${offset} exceeds object size ${fileStat.size}`);
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
const
|
|
257
|
+
const start = await alignOffsetToLineStart(handle, offset, fileStat.size);
|
|
258
|
+
const available = Math.max(0, fileStat.size - start);
|
|
222
259
|
const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
|
|
223
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length,
|
|
260
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
|
224
261
|
const sliced = sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
|
|
225
262
|
// sliceBufferChunk's nextOffset is relative to the window it was given (offset 0). Callers page
|
|
226
263
|
// with the absolute file offset from the header, so add the seek back.
|
|
227
|
-
const nextOffset =
|
|
228
|
-
return { ...sliced, nextOffset, eof: nextOffset >= fileStat.size };
|
|
264
|
+
const nextOffset = start + sliced.nextOffset;
|
|
265
|
+
return { ...sliced, offset: start, nextOffset, eof: nextOffset >= fileStat.size };
|
|
229
266
|
} finally {
|
|
230
267
|
await handle?.close();
|
|
231
268
|
}
|
package/src/guards.ts
ADDED