pi-continual-harness 0.1.0 → 0.2.0
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/LICENSE +21 -0
- package/README.md +27 -4
- package/package.json +8 -2
- package/src/harness.ts +110 -0
- package/src/index.ts +2 -0
- package/src/store.ts +189 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alessandro Pungitore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -13,8 +13,11 @@ with:
|
|
|
13
13
|
→ behavioral-file refinement (the "deep" path).
|
|
14
14
|
- **pi-mem / pi-memory** — durable memory storage.
|
|
15
15
|
|
|
16
|
-
The durable markdown
|
|
17
|
-
|
|
16
|
+
The durable markdown file (`.pi/harness-state.md`) is the composition seam,
|
|
17
|
+
and it is **two-way**: `/refine --commit` and `/harness export` write it;
|
|
18
|
+
`/harness import` parses it back and merges into the live store (offline edits
|
|
19
|
+
win on conflict), so refinements pi-reflect makes flow back online. pi-mem
|
|
20
|
+
ingestion is a future/manual step.
|
|
18
21
|
|
|
19
22
|
## Why
|
|
20
23
|
|
|
@@ -50,6 +53,24 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
50
53
|
/refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
|
|
51
54
|
```
|
|
52
55
|
|
|
56
|
+
Durable I/O (round-trip with pi-reflect):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/harness status # counts + durable file presence/mtime
|
|
60
|
+
/harness export [path] # write active items to a markdown file
|
|
61
|
+
/harness import [--prune] [path] # parse it back and merge (offline edits win)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`import` reconciles the file into the live store: items whose id matches an
|
|
65
|
+
existing entry are updated (offline edits win on content/evidence/importance),
|
|
66
|
+
new entries are created. By default nothing is deleted — `--prune` also drops
|
|
67
|
+
active items whose id is no longer in the file (inactive items are always
|
|
68
|
+
preserved). Point pi-reflect at the same file to refine it offline:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
/reflect ~/.pi/agent/harness-state.md
|
|
72
|
+
```
|
|
73
|
+
|
|
53
74
|
The model-facing tools:
|
|
54
75
|
|
|
55
76
|
- `harness_list [kind]` — read current state (optionally filtered by kind).
|
|
@@ -82,13 +103,15 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
|
82
103
|
|
|
83
104
|
## Status
|
|
84
105
|
|
|
85
|
-
|
|
106
|
+
0.1.x. The durable round-trip with pi-reflect is implemented
|
|
107
|
+
(`/harness import|export|status`). Open extension points:
|
|
86
108
|
|
|
87
109
|
- A pluggable delta proposer (currently the agent itself, via steering).
|
|
88
110
|
- Project-local vs global durable paths.
|
|
89
111
|
- Optional `turn_end` auto-trigger behind a setting (intentionally off by
|
|
90
112
|
default — autonomous self-mutation is a sharp edge).
|
|
91
|
-
- Importance decay/prune policy.
|
|
113
|
+
- Importance decay/prune policy — `decayAndPrune()` exists but is not yet wired.
|
|
114
|
+
- pi-mem ingestion of the durable export (manual/future).
|
|
92
115
|
|
|
93
116
|
## License
|
|
94
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Online self-improvement layer for pi. A unified, in-trajectory harness-state store (prompt notes, memory, skill descriptions, sub-agent specs) with a manual /refine that proposes evidence-backed structured CRUD deltas. Composes with pi-reflect (offline) and pi-mem (storage); owns only the online optimizer layer. Inspired by Continual Harness (arXiv 2605.09998) and ACE (arXiv 2510.04618).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"agent"
|
|
16
16
|
],
|
|
17
17
|
"license": "MIT",
|
|
18
|
+
"author": "alessandro@pungitore.ch",
|
|
18
19
|
"type": "module",
|
|
19
20
|
"main": "src/index.ts",
|
|
20
21
|
"files": [
|
|
@@ -44,6 +45,10 @@
|
|
|
44
45
|
"typescript": "^5.6.0",
|
|
45
46
|
"vitest": "^3.0.0"
|
|
46
47
|
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"provenance": true
|
|
51
|
+
},
|
|
47
52
|
"pi": {
|
|
48
53
|
"extensions": [
|
|
49
54
|
"./src/index.ts"
|
|
@@ -52,6 +57,7 @@
|
|
|
52
57
|
"scripts": {
|
|
53
58
|
"test": "vitest run",
|
|
54
59
|
"typecheck": "tsc --noEmit",
|
|
55
|
-
"check": "npm run typecheck && npm run test"
|
|
60
|
+
"check": "npm run typecheck && npm run test",
|
|
61
|
+
"prepublishOnly": "node -e \"if(!process.env.CI)throw new Error('publish via release.yml CI: push a v* tag. Manual npm publish is blocked to guarantee provenance + version sync.')\""
|
|
56
62
|
}
|
|
57
63
|
}
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// /harness — durable I/O. The two-way round-trip seam with pi-reflect.
|
|
2
|
+
//
|
|
3
|
+
// /harness status [path] counts + durable file presence/mtime
|
|
4
|
+
// /harness export [path] write active items to a markdown file
|
|
5
|
+
// /harness import [--prune] [path] parse it back and merge (durable wins)
|
|
6
|
+
//
|
|
7
|
+
// export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
|
|
8
|
+
// import parses that file and merges into the live store. Because pi-reflect
|
|
9
|
+
// edits markdown files and git-commits, pointing it at the same file closes the
|
|
10
|
+
// loop: offline refinement flows back into the online store.
|
|
11
|
+
//
|
|
12
|
+
// Manual only (no session_start auto-import): importing is an explicit,
|
|
13
|
+
// reviewable action, matching the package's "no autonomous mutation" stance.
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { stat } from "node:fs/promises";
|
|
17
|
+
import { DEFAULT_DURABLE_PATH, exportDurable, getState, reconstructFromDurable } from "./store.js";
|
|
18
|
+
import type { ComponentKind } from "./types.js";
|
|
19
|
+
|
|
20
|
+
export function registerHarness(pi: ExtensionAPI): void {
|
|
21
|
+
pi.registerCommand("harness", {
|
|
22
|
+
description:
|
|
23
|
+
"Durable harness-state I/O (round-trip seam with pi-reflect). " +
|
|
24
|
+
"Subcommands: import [--prune] [path] · export [path] · status [path].",
|
|
25
|
+
handler: async (args, ctx) => {
|
|
26
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
27
|
+
const sub = (parts[0] ?? "status").toLowerCase();
|
|
28
|
+
const rest = parts.slice(1);
|
|
29
|
+
switch (sub) {
|
|
30
|
+
case "import":
|
|
31
|
+
await handleImport(pi, ctx, rest);
|
|
32
|
+
return;
|
|
33
|
+
case "export":
|
|
34
|
+
await handleExport(ctx, rest);
|
|
35
|
+
return;
|
|
36
|
+
case "status":
|
|
37
|
+
default:
|
|
38
|
+
await handleStatus(ctx, rest);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolvePath(rest: string[]): string {
|
|
46
|
+
return rest.find((a) => !a.startsWith("-")) ?? DEFAULT_DURABLE_PATH;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function handleImport(
|
|
50
|
+
pi: ExtensionAPI,
|
|
51
|
+
ctx: ExtensionCommandContext,
|
|
52
|
+
rest: string[],
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const prune = rest.includes("--prune");
|
|
55
|
+
const path = resolvePath(rest);
|
|
56
|
+
ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
|
|
57
|
+
try {
|
|
58
|
+
const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
|
|
59
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
60
|
+
});
|
|
61
|
+
if (res.missingFile) {
|
|
62
|
+
ctx.ui.notify(
|
|
63
|
+
`No durable file at ${path}. Run /refine --commit or /harness export first.`,
|
|
64
|
+
"warning",
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const bits = [`${res.created} created`, `${res.updated} updated`];
|
|
69
|
+
if (prune) bits.push(`${res.pruned} pruned`);
|
|
70
|
+
ctx.ui.notify(`Imported ${res.imported} item(s) from ${path} (${bits.join(", ")}).`, "info");
|
|
71
|
+
} catch (err) {
|
|
72
|
+
ctx.ui.notify(`Harness import failed: ${(err as Error).message}`, "error");
|
|
73
|
+
}
|
|
74
|
+
ctx.ui.setStatus("harness", undefined);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
78
|
+
const path = resolvePath(rest);
|
|
79
|
+
ctx.ui.setStatus("harness", "Exporting durable state…");
|
|
80
|
+
try {
|
|
81
|
+
const written = await exportDurable(path);
|
|
82
|
+
const n = getState().items.filter((i) => i.active).length;
|
|
83
|
+
ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
ctx.ui.notify(`Harness export failed: ${(err as Error).message}`, "error");
|
|
86
|
+
}
|
|
87
|
+
ctx.ui.setStatus("harness", undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
91
|
+
const path = resolvePath(rest);
|
|
92
|
+
const items = getState().items;
|
|
93
|
+
const active = items.filter((i) => i.active);
|
|
94
|
+
const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
|
|
95
|
+
for (const i of active) counts[i.kind] += 1;
|
|
96
|
+
let fileState: string;
|
|
97
|
+
try {
|
|
98
|
+
const st = await stat(path);
|
|
99
|
+
fileState = `${path} (modified ${st.mtime.toISOString()})`;
|
|
100
|
+
} catch {
|
|
101
|
+
fileState = `none at ${path}`;
|
|
102
|
+
}
|
|
103
|
+
ctx.ui.setStatus("harness", undefined);
|
|
104
|
+
ctx.ui.notify(
|
|
105
|
+
`Harness: ${active.length} active / ${items.length} total — ` +
|
|
106
|
+
`prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}. ` +
|
|
107
|
+
`Durable: ${fileState}.`,
|
|
108
|
+
"info",
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// See README for design rationale and the research it is grounded in.
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerHarness } from "./harness.js";
|
|
10
11
|
import { registerInjection } from "./inject.js";
|
|
11
12
|
import { registerRefine } from "./refine.js";
|
|
12
13
|
import { registerTools } from "./tools.js";
|
|
@@ -27,6 +28,7 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
27
28
|
registerInjection(pi);
|
|
28
29
|
registerTools(pi);
|
|
29
30
|
registerRefine(pi);
|
|
31
|
+
registerHarness(pi);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export { STATE_ENTRY };
|
package/src/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// to a markdown file pi-reflect can read and refine offline, and pi-mem can
|
|
11
11
|
// ingest. Best-effort; the package works without it.
|
|
12
12
|
|
|
13
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } from "./types.js";
|
|
@@ -18,7 +18,7 @@ import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } fr
|
|
|
18
18
|
const STATE_ENTRY = "harness-state";
|
|
19
19
|
const REFINE_ENTRY = "harness-refinement";
|
|
20
20
|
|
|
21
|
-
const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
|
|
21
|
+
export const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
|
|
22
22
|
|
|
23
23
|
const IMPORTANCE_FLOOR = 0.3;
|
|
24
24
|
|
|
@@ -167,3 +167,190 @@ function titleFor(kind: ComponentKind): string {
|
|
|
167
167
|
return "Sub-agent specs";
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
// ---- Durable import (round-trip seam with pi-reflect) ---------------------
|
|
172
|
+
//
|
|
173
|
+
// exportDurable() is write-only by design. reconstructFromDurable() closes the
|
|
174
|
+
// loop: it parses the markdown back into items and merges them into the live
|
|
175
|
+
// store, so offline edits pi-reflect makes to harness-state.md flow back in.
|
|
176
|
+
//
|
|
177
|
+
// Merge semantics (predictable, loss-free by default):
|
|
178
|
+
// - parsed item whose id matches an existing item → UPDATE in place
|
|
179
|
+
// (durable wins on content/evidence/importance; reactivated; createdAt kept).
|
|
180
|
+
// - parsed item with a new/foreign id → CREATE.
|
|
181
|
+
// - items in the store but absent from the file → KEPT by default.
|
|
182
|
+
// Pass { prune: true } to also drop active items whose id is not in the
|
|
183
|
+
// file (inactive items are always preserved — the durable export never
|
|
184
|
+
// contains them, so they cannot have been "deleted" by pi-reflect).
|
|
185
|
+
|
|
186
|
+
export interface DurableImportResult {
|
|
187
|
+
/** Items successfully parsed from the file. */
|
|
188
|
+
imported: number;
|
|
189
|
+
created: number;
|
|
190
|
+
updated: number;
|
|
191
|
+
/** Only non-zero when { prune: true }. */
|
|
192
|
+
pruned: number;
|
|
193
|
+
/** True if the file did not exist (no-op). */
|
|
194
|
+
missingFile: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface ParsedItem {
|
|
198
|
+
id?: string;
|
|
199
|
+
kind: ComponentKind;
|
|
200
|
+
importance: number;
|
|
201
|
+
content: string;
|
|
202
|
+
evidence: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Section title → kind. Exact export titles first, then tolerant keyword
|
|
206
|
+
// fallbacks so pi-reflect's edits to headings still parse.
|
|
207
|
+
const TITLE_TO_KIND: Array<[RegExp, ComponentKind]> = [
|
|
208
|
+
[/supplemental prompt notes/i, "prompt"],
|
|
209
|
+
[/memory facts/i, "memory"],
|
|
210
|
+
[/skill descriptions/i, "skill"],
|
|
211
|
+
[/sub-?agent specs/i, "subagent"],
|
|
212
|
+
[/\bprompt\b/i, "prompt"],
|
|
213
|
+
[/\bmemory\b/i, "memory"],
|
|
214
|
+
[/\bskills?\b/i, "skill"],
|
|
215
|
+
[/\bsub-?agents?\b/i, "subagent"],
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
function titleToKind(title: string): ComponentKind | undefined {
|
|
219
|
+
for (const [re, kind] of TITLE_TO_KIND) if (re.test(title)) return kind;
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const RE_H2 = /^##\s+(.*)$/;
|
|
224
|
+
const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.*)$/;
|
|
225
|
+
const RE_PLAIN_BULLET = /^-\s+(.+)$/;
|
|
226
|
+
const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
|
|
227
|
+
|
|
228
|
+
/** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
|
|
229
|
+
export function parseDurable(text: string): ParsedItem[] {
|
|
230
|
+
const out: ParsedItem[] = [];
|
|
231
|
+
let kind: ComponentKind | undefined;
|
|
232
|
+
let pending: ParsedItem | null = null;
|
|
233
|
+
const flush = (): void => {
|
|
234
|
+
if (pending && pending.content) out.push(pending);
|
|
235
|
+
pending = null;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
239
|
+
const line = raw.replace(/\s+$/, "");
|
|
240
|
+
const h2 = line.match(RE_H2);
|
|
241
|
+
if (h2) {
|
|
242
|
+
flush();
|
|
243
|
+
kind = titleToKind(h2[1]!.trim());
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (/^#/.test(line)) {
|
|
247
|
+
flush(); // any other header (h1, h3, …)
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!kind) continue; // ignore bullets outside a known section
|
|
251
|
+
|
|
252
|
+
const ev = line.match(RE_EVIDENCE);
|
|
253
|
+
if (ev) {
|
|
254
|
+
if (pending) pending.evidence = ev[1]!.trim();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const idm = line.match(RE_ID_BULLET);
|
|
259
|
+
if (idm) {
|
|
260
|
+
flush();
|
|
261
|
+
const imp = Number(idm[2]!);
|
|
262
|
+
pending = {
|
|
263
|
+
id: idm[1]!.trim(),
|
|
264
|
+
kind,
|
|
265
|
+
importance: Number.isNaN(imp) ? 0.5 : imp,
|
|
266
|
+
content: idm[3]!.trim(),
|
|
267
|
+
evidence: "",
|
|
268
|
+
};
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pm = line.match(RE_PLAIN_BULLET);
|
|
273
|
+
if (pm) {
|
|
274
|
+
flush();
|
|
275
|
+
const body = pm[1]!.trim();
|
|
276
|
+
const idInBody = body.match(/^\*\*\[([^\]]+)\]\*\*/);
|
|
277
|
+
pending = {
|
|
278
|
+
kind,
|
|
279
|
+
importance: 0.5,
|
|
280
|
+
content: body.replace(/^\*\*\[([^\]]+)\]\*\*\s*/, "").trim(),
|
|
281
|
+
evidence: "",
|
|
282
|
+
};
|
|
283
|
+
if (idInBody) pending.id = idInBody[1]!.trim();
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
flush();
|
|
288
|
+
return out.filter((p) => p.content && !/^\(?no active items\)?$/i.test(p.content));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Parse the durable file and merge it into the live store, then persist a
|
|
293
|
+
* snapshot. See the file-level comment above for merge semantics.
|
|
294
|
+
*/
|
|
295
|
+
export async function reconstructFromDurable(
|
|
296
|
+
path: string,
|
|
297
|
+
options: { prune?: boolean },
|
|
298
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
299
|
+
): Promise<DurableImportResult> {
|
|
300
|
+
let text: string;
|
|
301
|
+
try {
|
|
302
|
+
text = await readFile(path, "utf8");
|
|
303
|
+
} catch {
|
|
304
|
+
return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const parsed = parseDurable(text);
|
|
308
|
+
const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
|
|
309
|
+
const existingById = new Map(state.items.map((i) => [i.id, i] as const));
|
|
310
|
+
const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
|
|
311
|
+
|
|
312
|
+
let created = 0;
|
|
313
|
+
let updated = 0;
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
for (const p of parsed) {
|
|
316
|
+
const existing = p.id ? existingById.get(p.id) : undefined;
|
|
317
|
+
if (existing) {
|
|
318
|
+
// Durable wins; reactivate; keep createdAt.
|
|
319
|
+
existing.content = p.content;
|
|
320
|
+
existing.evidence = p.evidence;
|
|
321
|
+
existing.importance = clamp(p.importance);
|
|
322
|
+
existing.active = true;
|
|
323
|
+
existing.updatedAt = now;
|
|
324
|
+
updated += 1;
|
|
325
|
+
} else {
|
|
326
|
+
const id = p.id && /^h_/.test(p.id) ? p.id : genId();
|
|
327
|
+
state.items.push({
|
|
328
|
+
id,
|
|
329
|
+
kind: p.kind,
|
|
330
|
+
content: p.content,
|
|
331
|
+
evidence: p.evidence,
|
|
332
|
+
importance: clamp(p.importance),
|
|
333
|
+
active: true,
|
|
334
|
+
createdAt: now,
|
|
335
|
+
updatedAt: now,
|
|
336
|
+
});
|
|
337
|
+
created += 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let pruned = 0;
|
|
342
|
+
if (options.prune) {
|
|
343
|
+
const before = state.items.length;
|
|
344
|
+
state.items = state.items.filter((i) => {
|
|
345
|
+
if (!i.active) return true; // inactive items are never touched by durable I/O
|
|
346
|
+
if (!preActiveIds.has(i.id)) return true; // created during this import
|
|
347
|
+
if (fileIds.has(i.id)) return true; // present in the file
|
|
348
|
+
return false; // was active before, absent from the file → drop
|
|
349
|
+
});
|
|
350
|
+
pruned = before - state.items.length;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
version += 1;
|
|
354
|
+
persist(state, version);
|
|
355
|
+
return { imported: parsed.length, created, updated, pruned, missingFile: false };
|
|
356
|
+
}
|