@pi-unipi/unipi 3.0.0 → 3.0.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/CHANGELOG.md +20 -1
- package/README.md +10 -134
- package/package.json +23 -23
- package/packages/app-bridge/package.json +2 -2
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/core/README.md +4 -1
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/src/settings/snapshot.ts +247 -0
- package/packages/dream/package.json +2 -2
- package/packages/footer/package.json +3 -3
- package/packages/fusion/package.json +4 -4
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +7 -7
- package/packages/long-horizon/README.md +1 -3
- package/packages/long-horizon/index.ts +131 -27
- package/packages/long-horizon/package.json +2 -2
- package/packages/long-horizon/src/owner.ts +33 -1
- package/packages/long-horizon/src/session-state.ts +366 -0
- package/packages/long-horizon/src/tools/graph.ts +10 -6
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +3 -3
- package/packages/notify/package.json +2 -2
- package/packages/skill-registry/package.json +2 -2
- package/packages/subagents/package.json +3 -3
- package/packages/subagents/src/index.ts +29 -5
- package/packages/unipi/bundled.js +950 -464
- package/packages/unipi/index.ts +18 -1
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/watchdog/package.json +3 -3
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings snapshot — the settings catalog published to disk for OTHER
|
|
3
|
+
* processes (the unipi-host serves it to the app's Settings → UniPi screen;
|
|
4
|
+
* docs: `~/.unipi/config/settings-schema.json`).
|
|
5
|
+
*
|
|
6
|
+
* `buildSettingsSnapshot()` flattens the live registry (engine.ts) into plain
|
|
7
|
+
* JSON: getters are resolved (order items, page sections), functions are
|
|
8
|
+
* dropped (`validate` → `validated: true`, model `filter` → `filtered: true`),
|
|
9
|
+
* and anything that throws while reading is skipped — a broken getter in one
|
|
10
|
+
* module must never blank the whole catalog.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { listSettingsDefinitions, type SettingsDefinition } from "./engine.js";
|
|
17
|
+
import { unipiConfigRoot } from "./paths.js";
|
|
18
|
+
import { enumOption, type SettingsField, type SettingsSection } from "./schema.js";
|
|
19
|
+
|
|
20
|
+
export interface SettingsSnapshotNamespace {
|
|
21
|
+
readonly namespace: string;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly defaults: Record<string, unknown>;
|
|
24
|
+
readonly projectOverrides: boolean;
|
|
25
|
+
/** The module mirrors writes into another program's config (an `onSet` hook) — the host cannot run it, so the app must not write these. */
|
|
26
|
+
readonly mirrored: boolean;
|
|
27
|
+
readonly sections: readonly SettingsSnapshotSection[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SettingsSnapshotSection {
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly description?: string;
|
|
33
|
+
readonly advanced?: boolean;
|
|
34
|
+
readonly fields: readonly SnapshotField[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A field as plain JSON — no functions; getters already resolved. */
|
|
38
|
+
export type SnapshotField =
|
|
39
|
+
| ({ readonly key: string; readonly type: "boolean"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[] } & Validated)
|
|
40
|
+
| ({ readonly key: string; readonly type: "enum"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly options: readonly { readonly value: string; readonly label: string; readonly description?: string }[]; readonly plainOptions?: boolean; readonly allowCustom?: boolean; readonly hint?: string; readonly scope?: "global"; readonly clearable?: boolean } & Validated)
|
|
41
|
+
| ({ readonly key: string; readonly type: "string" | "secret"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly emptyLabel?: string; readonly hint?: string } & Validated)
|
|
42
|
+
| ({ readonly key: string; readonly type: "number"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly min?: number; readonly max?: number; readonly unit?: string; readonly zeroLabel?: string; readonly hint?: string } & Validated)
|
|
43
|
+
| ({ readonly key: string; readonly type: "model"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly provider?: string; readonly capability?: string; readonly emptyLabel?: string; readonly presets?: readonly string[]; readonly presetsByProvider?: Readonly<Record<string, readonly string[]>>; readonly emptyOption?: string; readonly providerKey?: string; readonly filtered?: boolean } & Validated)
|
|
44
|
+
| ({ readonly key: string; readonly type: "multiselect"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly options: readonly { readonly value: string; readonly label: string; readonly description?: string }[]; readonly plainOptions?: boolean; readonly emptyLabel?: string } & Validated)
|
|
45
|
+
| ({ readonly key: string; readonly type: "order"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly items: readonly { readonly value: string; readonly label: string }[] } & Validated)
|
|
46
|
+
| ({ readonly key: string; readonly type: "page"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly sections: readonly SettingsSnapshotSection[] } & Validated)
|
|
47
|
+
| ({ readonly key: string; readonly type: "action"; readonly label: string; readonly description?: string; readonly scopes?: readonly string[]; readonly command: string } & Validated);
|
|
48
|
+
|
|
49
|
+
/** The raw-input validation gate stays in pi; the flag says input beyond the type checks is pi-validated. */
|
|
50
|
+
interface Validated {
|
|
51
|
+
readonly validated?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SettingsSnapshot {
|
|
55
|
+
readonly version: 1;
|
|
56
|
+
readonly writtenAt: string;
|
|
57
|
+
/** Best-effort: the @pi-unipi/core package version, when resolvable. */
|
|
58
|
+
readonly unipiVersion?: string;
|
|
59
|
+
readonly namespaces: readonly SettingsSnapshotNamespace[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The core package's version, read once (best-effort — a bundled install may not carry package.json). */
|
|
63
|
+
function coreVersion(): string | undefined {
|
|
64
|
+
try {
|
|
65
|
+
const pkg = new URL("../../package.json", import.meta.url);
|
|
66
|
+
const parsed = JSON.parse(readFileSync(fileURLToPath(pkg), "utf8")) as { version?: unknown };
|
|
67
|
+
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Serialise one field; `undefined` = skip it (a getter threw / unknown shape). */
|
|
74
|
+
function snapshotField(field: SettingsField): SnapshotField | undefined {
|
|
75
|
+
const base = {
|
|
76
|
+
key: field.key,
|
|
77
|
+
label: field.label,
|
|
78
|
+
...(field.description !== undefined ? { description: field.description } : {}),
|
|
79
|
+
...(field.scopes !== undefined ? { scopes: [...field.scopes] } : {}),
|
|
80
|
+
// validate stays in pi; the flag keeps the fact visible in the catalog.
|
|
81
|
+
validated: "validate" in field && typeof field.validate === "function",
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
switch (field.type) {
|
|
85
|
+
case "boolean":
|
|
86
|
+
return { ...base, type: "boolean" };
|
|
87
|
+
case "enum":
|
|
88
|
+
return {
|
|
89
|
+
...base,
|
|
90
|
+
type: "enum",
|
|
91
|
+
options: field.options.map(enumOption),
|
|
92
|
+
...(field.plainOptions !== undefined ? { plainOptions: field.plainOptions } : {}),
|
|
93
|
+
...(field.allowCustom !== undefined ? { allowCustom: field.allowCustom } : {}),
|
|
94
|
+
...(field.hint !== undefined ? { hint: field.hint } : {}),
|
|
95
|
+
...(field.scope !== undefined ? { scope: field.scope } : {}),
|
|
96
|
+
...(field.clearable !== undefined ? { clearable: field.clearable } : {}),
|
|
97
|
+
};
|
|
98
|
+
case "string":
|
|
99
|
+
case "secret":
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
type: field.type,
|
|
103
|
+
...(field.emptyLabel !== undefined ? { emptyLabel: field.emptyLabel } : {}),
|
|
104
|
+
...(field.hint !== undefined ? { hint: field.hint } : {}),
|
|
105
|
+
};
|
|
106
|
+
case "number":
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
type: "number",
|
|
110
|
+
...(field.min !== undefined ? { min: field.min } : {}),
|
|
111
|
+
...(field.max !== undefined ? { max: field.max } : {}),
|
|
112
|
+
...(field.unit !== undefined ? { unit: field.unit } : {}),
|
|
113
|
+
...(field.zeroLabel !== undefined ? { zeroLabel: field.zeroLabel } : {}),
|
|
114
|
+
...(field.hint !== undefined ? { hint: field.hint } : {}),
|
|
115
|
+
};
|
|
116
|
+
case "model":
|
|
117
|
+
return {
|
|
118
|
+
...base,
|
|
119
|
+
type: "model",
|
|
120
|
+
...(field.provider !== undefined ? { provider: field.provider } : {}),
|
|
121
|
+
...(field.capability !== undefined ? { capability: field.capability } : {}),
|
|
122
|
+
...(field.emptyLabel !== undefined ? { emptyLabel: field.emptyLabel } : {}),
|
|
123
|
+
...(field.presets !== undefined ? { presets: [...field.presets] } : {}),
|
|
124
|
+
...(field.presetsByProvider !== undefined
|
|
125
|
+
? { presetsByProvider: Object.fromEntries(Object.entries(field.presetsByProvider).map(([k, v]) => [k, [...v]])) }
|
|
126
|
+
: {}),
|
|
127
|
+
...(field.emptyOption !== undefined ? { emptyOption: field.emptyOption } : {}),
|
|
128
|
+
...(field.providerKey !== undefined ? { providerKey: field.providerKey } : {}),
|
|
129
|
+
// The filter is a function; only the fact that the catalog is filtered survives.
|
|
130
|
+
...(field.filter !== undefined ? { filtered: true } : {}),
|
|
131
|
+
};
|
|
132
|
+
case "multiselect":
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
type: "multiselect",
|
|
136
|
+
options: field.options.map(enumOption),
|
|
137
|
+
...(field.plainOptions !== undefined ? { plainOptions: field.plainOptions } : {}),
|
|
138
|
+
...(field.emptyLabel !== undefined ? { emptyLabel: field.emptyLabel } : {}),
|
|
139
|
+
};
|
|
140
|
+
case "order":
|
|
141
|
+
// Resolve the universe now: the host/app must not need the module.
|
|
142
|
+
return { ...base, type: "order", items: field.items().map((i) => ({ value: i.value, label: i.label })) };
|
|
143
|
+
case "page":
|
|
144
|
+
// Resolve the sections getter now; nested pages recurse, summary stays pi-side.
|
|
145
|
+
return { ...base, type: "page", sections: snapshotSections(typeof field.sections === "function" ? field.sections() : field.sections) };
|
|
146
|
+
case "action":
|
|
147
|
+
return { ...base, type: "action", command: field.command };
|
|
148
|
+
default:
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// A throwing getter (order items, page sections) skips just this field.
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function snapshotSection(section: SettingsSection): SettingsSnapshotSection | undefined {
|
|
158
|
+
try {
|
|
159
|
+
const fields: SnapshotField[] = [];
|
|
160
|
+
for (const field of section.fields) {
|
|
161
|
+
const snap = snapshotField(field);
|
|
162
|
+
if (snap) fields.push(snap);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
title: section.title,
|
|
166
|
+
...(section.description !== undefined ? { description: section.description } : {}),
|
|
167
|
+
...(section.advanced !== undefined ? { advanced: section.advanced } : {}),
|
|
168
|
+
fields,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function snapshotSections(sections: readonly SettingsSection[]): SettingsSnapshotSection[] {
|
|
176
|
+
const out: SettingsSnapshotSection[] = [];
|
|
177
|
+
for (const section of sections) {
|
|
178
|
+
const snap = snapshotSection(section);
|
|
179
|
+
if (snap) out.push(snap);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function snapshotNamespace(definition: SettingsDefinition): SettingsSnapshotNamespace {
|
|
185
|
+
return {
|
|
186
|
+
namespace: definition.namespace,
|
|
187
|
+
label: definition.label,
|
|
188
|
+
defaults: definition.defaults,
|
|
189
|
+
projectOverrides: definition.projectOverrides !== false,
|
|
190
|
+
mirrored: typeof definition.onSet === "function",
|
|
191
|
+
sections: snapshotSections(definition.schema ?? []),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Flatten the live settings registry into plain, getter-free JSON. */
|
|
196
|
+
export function buildSettingsSnapshot(): SettingsSnapshot {
|
|
197
|
+
const version = coreVersion();
|
|
198
|
+
return {
|
|
199
|
+
version: 1,
|
|
200
|
+
writtenAt: new Date().toISOString(),
|
|
201
|
+
...(version !== undefined ? { unipiVersion: version } : {}),
|
|
202
|
+
namespaces: listSettingsDefinitions().map(snapshotNamespace),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SNAPSHOT_FILE = "settings-schema.json";
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Publish the snapshot to `~/.unipi/config/settings-schema.json` — atomically
|
|
210
|
+
* (tmp + rename), and only when the content other than `writtenAt` changed
|
|
211
|
+
* (a clock-only diff must not dirty the file on every session start). Never
|
|
212
|
+
* throws: publishing is best-effort by design.
|
|
213
|
+
*/
|
|
214
|
+
export function writeSettingsSnapshot(): void {
|
|
215
|
+
try {
|
|
216
|
+
const snapshot = buildSettingsSnapshot();
|
|
217
|
+
const file = joinPath(unipiConfigRoot(), SNAPSHOT_FILE);
|
|
218
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
219
|
+
const previous = readPrevious(file);
|
|
220
|
+
if (previous && sameExceptWrittenAt(previous, snapshot)) return;
|
|
221
|
+
const tmp = `${file}.tmp`;
|
|
222
|
+
writeFileSync(tmp, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
223
|
+
renameSync(tmp, file);
|
|
224
|
+
} catch {
|
|
225
|
+
// Best-effort: no snapshot, no crash.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function joinPath(root: string, file: string): string {
|
|
230
|
+
return root.endsWith("/") ? `${root}${file}` : `${root}/${file}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readPrevious(file: string): SettingsSnapshot | undefined {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as SettingsSnapshot;
|
|
236
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
237
|
+
} catch {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Everything except `writtenAt` must be equal to skip the rewrite. */
|
|
243
|
+
function sameExceptWrittenAt(a: SettingsSnapshot, b: SettingsSnapshot): boolean {
|
|
244
|
+
const { writtenAt: _a, ...restA } = a as SettingsSnapshot & { writtenAt?: string };
|
|
245
|
+
const { writtenAt: _b, ...restB } = b as SettingsSnapshot & { writtenAt?: string };
|
|
246
|
+
return JSON.stringify(restA) === JSON.stringify(restB);
|
|
247
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/dream",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "A pi extension that dreams: reviews past session struggles in the background, turns repeated false paths into memory lessons, and crafts skills from errors — part of the Unipi suite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "extensions/dream.ts",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@earendil-works/pi-tui": "^0.87.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "3.0.
|
|
42
|
+
"@pi-unipi/core": "3.0.1"
|
|
43
43
|
},
|
|
44
44
|
"pi": {
|
|
45
45
|
"extensions": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/footer",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "3.0.
|
|
36
|
-
"@pi-unipi/background-tasks": "3.0.
|
|
35
|
+
"@pi-unipi/core": "3.0.1",
|
|
36
|
+
"@pi-unipi/background-tasks": "3.0.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "3.0.
|
|
36
|
-
"@pi-unipi/subagents": "3.0.
|
|
37
|
-
"@pi-unipi/utility": "3.0.
|
|
35
|
+
"@pi-unipi/core": "3.0.1",
|
|
36
|
+
"@pi-unipi/subagents": "3.0.1",
|
|
37
|
+
"@pi-unipi/utility": "3.0.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@earendil-works/pi-ai": "^0.87.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/info-screen",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Unicrab startup splash and the /unipi:info dashboard — this session, context, usage, compaction savings",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "3.0.
|
|
36
|
+
"@pi-unipi/core": "3.0.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/input-shortcuts",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "3.0.
|
|
36
|
+
"@pi-unipi/core": "3.0.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@pi-unipi/core": "3.0.
|
|
34
|
+
"@pi-unipi/core": "3.0.1"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"themes": []
|
|
49
49
|
},
|
|
50
50
|
"optionalDependencies": {
|
|
51
|
-
"@pi-unipi/kanboard-linux-x64": "3.0.
|
|
52
|
-
"@pi-unipi/kanboard-linux-arm64": "3.0.
|
|
53
|
-
"@pi-unipi/kanboard-darwin-x64": "3.0.
|
|
54
|
-
"@pi-unipi/kanboard-darwin-arm64": "3.0.
|
|
55
|
-
"@pi-unipi/kanboard-win32-x64": "3.0.
|
|
51
|
+
"@pi-unipi/kanboard-linux-x64": "3.0.1",
|
|
52
|
+
"@pi-unipi/kanboard-linux-arm64": "3.0.1",
|
|
53
|
+
"@pi-unipi/kanboard-darwin-x64": "3.0.1",
|
|
54
|
+
"@pi-unipi/kanboard-darwin-arm64": "3.0.1",
|
|
55
|
+
"@pi-unipi/kanboard-win32-x64": "3.0.1"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -111,9 +111,7 @@ Next comes the active owner, then the judge, then `defaultMode`. The gate adds
|
|
|
111
111
|
the mode prompt and filters the tool list. Within one mode, the tool list stays
|
|
112
112
|
the same, so the prefix cache stays valid.
|
|
113
113
|
|
|
114
|
-
The engine keeps owner state in `~/.unipi/workspace/<id>/state/long-horizon
|
|
115
|
-
Ralph checklists are in the `ralph/` folder there. Owners do not start inside a
|
|
116
|
-
child agent.
|
|
114
|
+
The engine keeps owner state in `~/.unipi/workspace/<id>/state/long-horizon/sessions/<sessionId>/` — a run belongs to the session that started it, so a new session starts clean (it gets a one-line note when another session holds a live graph; resuming the owning session continues it, and the first resume of the owning session picks up pre-3.0.1 runs). Ralph checklists are in the `ralph/` folder there. Owners do not start inside a child agent.
|
|
117
115
|
|
|
118
116
|
Read [Long-horizon architecture](../../docs/architecture/long-horizon.md) and
|
|
119
117
|
[Turn arbiter](../../docs/architecture/turn-arbiter.md).
|
|
@@ -28,6 +28,22 @@ import { countStatuses, GraphEngine, type GraphMode } from "./src/graph/engine.j
|
|
|
28
28
|
import { graphRunnerFromPort, readGraphRunPort } from "./src/graph/runner.js";
|
|
29
29
|
import { TodoStore, registerTodoTool } from "./src/tools/todo.js";
|
|
30
30
|
import { wireRuntime } from "./src/runtime.js";
|
|
31
|
+
import {
|
|
32
|
+
adoptLegacyState,
|
|
33
|
+
legacyOwnershipEvidence,
|
|
34
|
+
graphUpdatedAt as graphUpdatedAtFallback,
|
|
35
|
+
aggregateGraphHistory,
|
|
36
|
+
findSiblingWork,
|
|
37
|
+
graphOwnerNote,
|
|
38
|
+
isContinuedSession,
|
|
39
|
+
legacyInRepoDir,
|
|
40
|
+
legacyWorkspaceDir,
|
|
41
|
+
pruneSiblingSessionDirs,
|
|
42
|
+
scanSiblingGraphOwners,
|
|
43
|
+
sessionHasState,
|
|
44
|
+
sessionStateDir,
|
|
45
|
+
type SiblingGraphOwner,
|
|
46
|
+
} from "./src/session-state.js";
|
|
31
47
|
import { ralphProgressData } from "./src/progress.js";
|
|
32
48
|
import { lhProgressSnapshot, sameProgress, withProgressLog, type ProgressGoalEstimate } from "./src/progress-snapshot.js";
|
|
33
49
|
import { registerVisualizeProgress } from "./src/visualize.js";
|
|
@@ -44,19 +60,21 @@ const LH_DIR = ".unipi/long-horizon";
|
|
|
44
60
|
* if the new file is absent but the legacy in-repo <cwd>/.unipi/long-horizon/
|
|
45
61
|
* file exists, move it over on first access.
|
|
46
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* UNI-followup: a run belongs to the session that started it — the dir is
|
|
65
|
+
* `.../long-horizon/sessions/<sessionId>/` once session_start has stamped
|
|
66
|
+
* `currentSessionId` (before that, pre-session access still lands in the
|
|
67
|
+
* legacy workspace-level dir). The legacy workspace-level files and the
|
|
68
|
+
* older in-repo `.unipi/long-horizon/` files are adopted into the session
|
|
69
|
+
* dir at session_start (continued sessions only, see the adoption there).
|
|
70
|
+
*/
|
|
71
|
+
let currentSessionId: string | undefined;
|
|
72
|
+
const workspaceStateDir = (): string => stateDir("long-horizon", "state");
|
|
73
|
+
const lhSessionsDir = (): string => join(workspaceStateDir(), "sessions");
|
|
74
|
+
|
|
47
75
|
function lhStatePath(file: string): string {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const legacy = join(resolve(process.cwd()), LH_DIR, file);
|
|
51
|
-
if (existsSync(legacy)) {
|
|
52
|
-
try {
|
|
53
|
-
renameSync(legacy, dest);
|
|
54
|
-
} catch {
|
|
55
|
-
// Fall through: a failed move just means we start fresh at dest.
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return dest;
|
|
76
|
+
const dir = currentSessionId ? sessionStateDir(workspaceStateDir(), currentSessionId) : workspaceStateDir();
|
|
77
|
+
return join(dir, file);
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
export default function longHorizon(pi: ExtensionAPI): void {
|
|
@@ -126,8 +144,10 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
126
144
|
}
|
|
127
145
|
// Bound once the graph engine exists (owner events can fire before that).
|
|
128
146
|
let graphOwnerChanged: ((event: OwnerEvent) => void) | undefined;
|
|
147
|
+
const sessionInfo: { id?: string; name?: string; pid?: number } = {};
|
|
129
148
|
const owner = new OwnerCoordinator({
|
|
130
149
|
statePath,
|
|
150
|
+
session: () => sessionInfo,
|
|
131
151
|
onChange: (_snapshot, event: OwnerEvent) => {
|
|
132
152
|
// Owner transitions sync pi's active set (UNI-90): activate/resume/restore
|
|
133
153
|
// outside before_agent_start must expose the owner's mode tools at once.
|
|
@@ -567,8 +587,56 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
567
587
|
// reattaches the owner's tool surface; the continuation arms a recovery
|
|
568
588
|
// fragment for the first post-restart turn.
|
|
569
589
|
pi.on("session_start", (event, ctx) => {
|
|
570
|
-
//
|
|
590
|
+
// UNI-followup: stamp the session id FIRST — every lhStatePath() read below
|
|
591
|
+
// (restore/bind) must land in THIS session's dir before any restore runs.
|
|
571
592
|
const reason = (event as { reason?: string } | undefined)?.reason;
|
|
593
|
+
const manager = (ctx as { sessionManager?: { getSessionId?: () => string; getEntries?: () => unknown[] } } | undefined)?.sessionManager;
|
|
594
|
+
try {
|
|
595
|
+
currentSessionId = manager?.getSessionId?.();
|
|
596
|
+
} catch {
|
|
597
|
+
// Keep the legacy workspace dir when the id is unavailable.
|
|
598
|
+
}
|
|
599
|
+
sessionInfo.id = currentSessionId;
|
|
600
|
+
try {
|
|
601
|
+
const name = (pi as unknown as { getSessionName?: () => string | undefined }).getSessionName?.();
|
|
602
|
+
if (name) sessionInfo.name = name;
|
|
603
|
+
} catch {
|
|
604
|
+
// Name is cosmetic.
|
|
605
|
+
}
|
|
606
|
+
sessionInfo.pid = process.pid;
|
|
607
|
+
const priorEntries = (() => {
|
|
608
|
+
try {
|
|
609
|
+
return manager?.getEntries?.().length ?? 0;
|
|
610
|
+
} catch {
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
})();
|
|
614
|
+
const continued = isContinuedSession(reason, priorEntries);
|
|
615
|
+
|
|
616
|
+
// Legacy adoption (continued sessions only, and only when THIS session's
|
|
617
|
+
// own transcript proves it owned the run — the entries must contain the
|
|
618
|
+
// legacy graph id / goal id / objective / loop name). Otherwise the files
|
|
619
|
+
// stay put: an unrelated `pi -r` must not adopt (and drive) someone's
|
|
620
|
+
// graph — that would re-introduce the cross-session wake bug. The
|
|
621
|
+
// sibling-owner note still points the user at it.
|
|
622
|
+
const legacyDirs = [legacyWorkspaceDir(workspaceStateDir()), legacyInRepoDir(resolve(process.cwd()))];
|
|
623
|
+
const entriesJson = (() => {
|
|
624
|
+
try {
|
|
625
|
+
return JSON.stringify(manager?.getEntries?.() ?? []);
|
|
626
|
+
} catch {
|
|
627
|
+
return "";
|
|
628
|
+
}
|
|
629
|
+
})();
|
|
630
|
+
const legacyEvidence = continued && !sessionHasState(sessionStateDir(workspaceStateDir(), currentSessionId ?? ""))
|
|
631
|
+
? legacyOwnershipEvidence(entriesJson, legacyDirs)
|
|
632
|
+
: false;
|
|
633
|
+
if (continued && legacyEvidence && !sessionHasState(sessionStateDir(workspaceStateDir(), currentSessionId ?? ""))) {
|
|
634
|
+
adoptLegacyState(sessionStateDir(workspaceStateDir(), currentSessionId ?? ""), legacyDirs, ["state.json", "goal.json", "graph.json"]);
|
|
635
|
+
}
|
|
636
|
+
// Housekeeping: siblings with no owner, no open graph, >30 days old.
|
|
637
|
+
if (currentSessionId) pruneSiblingSessionDirs(lhSessionsDir(), currentSessionId, Date.now());
|
|
638
|
+
|
|
639
|
+
// A sticky mode belongs to the session that picked it (UNI-165).
|
|
572
640
|
if (reason === "new" || reason === "resume" || reason === "fork") gate.dropStickyWorkMode();
|
|
573
641
|
// Mode tools start OFF; a restored active owner re-syncs them ON below.
|
|
574
642
|
gate.resetModeTools();
|
|
@@ -601,10 +669,10 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
601
669
|
// otherwise the recovery fragment rides the next continuation.
|
|
602
670
|
const goalStatus = machine.get()?.status;
|
|
603
671
|
if (goalStatus === "active" || goalStatus === "waiting") {
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
const redeliver = !isChildProcess() &&
|
|
672
|
+
// State is session-scoped, so this is always THIS session's pending
|
|
673
|
+
// continuation. Print mode (no UI) owns its own prompt: never race it
|
|
674
|
+
// with a wake.
|
|
675
|
+
const redeliver = !isChildProcess() && (ctx as { hasUI?: boolean } | undefined)?.hasUI !== false;
|
|
608
676
|
const recovery = redeliver ? continuation.recoverOnRestart() : (continuation.armRecovery(), undefined);
|
|
609
677
|
if (recovery?.action === "redeliver") {
|
|
610
678
|
// Outside any turn: a direct wake once startup has finished.
|
|
@@ -614,6 +682,21 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
614
682
|
}, 0);
|
|
615
683
|
}
|
|
616
684
|
}
|
|
685
|
+
// UNI-followup: one-line note when another session holds a live graph —
|
|
686
|
+
// never reaches the model (ui.notify is user-only) and never starts a turn.
|
|
687
|
+
if (!isChildProcess() && (reason === "new" || reason === "fork" || reason === "startup" || reason === undefined) && (ctx as { hasUI?: boolean } | undefined)?.hasUI) {
|
|
688
|
+
try {
|
|
689
|
+
const siblings = scanSiblingGraphOwners(lhSessionsDir(), currentSessionId ?? "", legacyWorkspaceDir(workspaceStateDir()), legacyInRepoDir(resolve(process.cwd())))
|
|
690
|
+
.filter((o: SiblingGraphOwner) => o.sessionId !== currentSessionId);
|
|
691
|
+
const note = graphOwnerNote(siblings[0]);
|
|
692
|
+
if (note) {
|
|
693
|
+
const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
|
|
694
|
+
ui?.notify?.(note, "info");
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// The note is best-effort; a failed scan must not break startup.
|
|
698
|
+
}
|
|
699
|
+
}
|
|
617
700
|
// Resume (`pi -r`) starts no turn, so before_agent_start never fires.
|
|
618
701
|
// Publish unconditionally so the header restores without depending on a
|
|
619
702
|
// one-shot event beating the footer's subscription.
|
|
@@ -627,8 +710,17 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
627
710
|
const a = (args ?? {}) as { op?: unknown; id?: unknown; graphId?: unknown };
|
|
628
711
|
const op = String(a.op ?? "");
|
|
629
712
|
if (op === "output") {
|
|
630
|
-
const
|
|
631
|
-
|
|
713
|
+
const graphId = typeof a.graphId === "string" ? a.graphId : undefined;
|
|
714
|
+
const found = graphEngine.readResult(String(a.id ?? ""), graphId);
|
|
715
|
+
// UNI-followup: history spans the project — find results in sibling
|
|
716
|
+
// sessions' FINISHED graphs too (read-only; never an open graph).
|
|
717
|
+
const sibling = !found && graphId && currentSessionId ? findSiblingWork(lhSessionsDir(), currentSessionId, graphId, String(a.id ?? "")) : undefined;
|
|
718
|
+
if (!found) {
|
|
719
|
+
if (!sibling) return { ok: false, reason: "That step is gone." };
|
|
720
|
+
const w = sibling.work as { workId?: unknown; status?: unknown; resultId?: unknown; operatorId?: unknown; instruction?: unknown; result?: unknown; failure?: unknown; resultTruncated?: unknown };
|
|
721
|
+
return { ok: true, output: { graphId, id: String(w.workId ?? a.id ?? ""), status: String(w.status ?? ""), ...(w.resultId ? { resultId: w.resultId } : {}), ...(w.operatorId ? { operatorId: w.operatorId } : {}), instruction: String(w.instruction ?? ""), ...(w.result ? { text: w.result } : {}), ...(w.failure ? { failure: w.failure } : {}), ...(w.resultTruncated ? { truncated: true } : {}) } };
|
|
722
|
+
}
|
|
723
|
+
// This session's own copy (authoritative).
|
|
632
724
|
const w = found.work;
|
|
633
725
|
return { ok: true, output: { graphId: found.graphId, id: w.workId, status: w.status, ...(w.resultId ? { resultId: w.resultId } : {}), ...(w.operatorId ? { operatorId: w.operatorId } : {}), instruction: w.instruction, ...(w.result ? { text: w.result } : {}), ...(w.failure ? { failure: w.failure } : {}), ...(w.resultTruncated ? { truncated: true } : {}) } };
|
|
634
726
|
}
|
|
@@ -640,14 +732,26 @@ export default function longHorizon(pi: ExtensionAPI): void {
|
|
|
640
732
|
return { ok: true };
|
|
641
733
|
}
|
|
642
734
|
if (op === "history") {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
735
|
+
// UNI-followup: the project's graph history — this engine's own history
|
|
736
|
+
// plus siblings' FINISHED graphs (history entries and a closed current;
|
|
737
|
+
// never another session's open graph), deduped by graphId, newest first.
|
|
738
|
+
const aggregate = currentSessionId
|
|
739
|
+
? aggregateGraphHistory(graphEngine.history().map((g) => g as unknown as Record<string, unknown> & { graphId?: string }), lhSessionsDir(), currentSessionId)
|
|
740
|
+
: graphEngine.history().map((g) => g as unknown as Record<string, unknown> & { graphId?: string });
|
|
741
|
+
const runs = aggregate
|
|
742
|
+
.slice(0, 20)
|
|
743
|
+
.map((g) => {
|
|
744
|
+
const graph = g as unknown as Parameters<typeof lhProgressSnapshot>[0]["graph"];
|
|
745
|
+
const finish = g["finish"] as { at?: number } | undefined;
|
|
746
|
+
const abandoned = g["abandoned"] as { reason?: string; at?: number } | undefined;
|
|
747
|
+
return lhProgressSnapshot({
|
|
748
|
+
finished: { kind: (g["mode"] as "graph" | "swarm"), label: String(g["task"] ?? ""), terminalReason: finish ? "complete(finish)" : (abandoned?.reason ?? "stopped"), endedAt: new Date(finish?.at ?? abandoned?.at ?? graphUpdatedAtFallback(g)).toISOString() },
|
|
749
|
+
graph,
|
|
750
|
+
graphEngine,
|
|
751
|
+
now: Date.now(),
|
|
752
|
+
}).last;
|
|
753
|
+
})
|
|
754
|
+
.filter((run) => run !== undefined);
|
|
651
755
|
return { ok: true, runs };
|
|
652
756
|
}
|
|
653
757
|
return { ok: false, reason: `unknown op "${op}"` };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/long-horizon",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Long-horizon execution for Pi — mode-gated /goal /ralph /swarm /graph orchestration with a TypeSafe prompt judge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@pi-unipi/core": "3.0.
|
|
40
|
+
"@pi-unipi/core": "3.0.1",
|
|
41
41
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
42
42
|
"beautiful-mermaid": "^1.1.3"
|
|
43
43
|
},
|