pi-longhaul 1.0.2 → 1.0.3
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 +32 -0
- package/README.md +3 -3
- package/docs/security.md +5 -0
- package/package.json +5 -5
- package/pi-compat.json +9 -0
- package/src/store/events.ts +4 -1
- package/src/store/log.ts +25 -5
- package/src/store/project.ts +74 -8
- package/src/store/recall.ts +20 -16
- package/src/store/verify.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,38 @@ entries and log event types. Rendered state text, prompt wording and section lay
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [v1.0.3] - 2026-10-22
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Tested with pi 1.3.0, now recorded in `pi-compat.json`. No code changes to the extension for it.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A span's record was lost whenever another extension wrote an entry between pi's compaction and Longhaul's span
|
|
20
|
+
record. pi-goal does this at every compaction (its `goal-state`, sometimes a `goal-contract` message), active
|
|
21
|
+
goal or not, so in a session with pi-goal installed most spans folded as narrative-less: the next state kept
|
|
22
|
+
only the span just closed, and every earlier span's work-log line, Now, decisions, dead ends, objective moves and
|
|
23
|
+
labelled rules were gone from it. The fold now passes over other extensions' entries until our record arrives; a
|
|
24
|
+
message, a compaction or one of Longhaul's own event entries arriving first still means the record never
|
|
25
|
+
landed. The records were always written, so an affected transcript gets its history back at its next
|
|
26
|
+
compaction. While its records were lost, each pass was shown earlier messages again and recorded their rules
|
|
27
|
+
again under new ids; with the records back, a rule recorded from the same message in near-duplicate words is
|
|
28
|
+
one rule, under its newest id, and a lift of any copy lifts it. A rule the user withdrew while its record was
|
|
29
|
+
lost could not be lifted then (the summarizer did not see it) and comes back now, so look over the
|
|
30
|
+
constraints the first state after the upgrade shows.
|
|
31
|
+
- Spans whose records were written while earlier records were being lost can carry the same event seqs as an
|
|
32
|
+
earlier span (each pass numbered from a fold that had lost events). The fold now treats a seq as unique within
|
|
33
|
+
its span, so those spans fold too; a seq repeated inside one span is still refused as a duplicate. A correction
|
|
34
|
+
`longhaul_recall` shows as lifted is now the one the lift landed on, never another span's event with the same
|
|
35
|
+
seq.
|
|
36
|
+
- A replayed event whose span or seq is not a whole number from 1 is refused as malformed. A seq of `1e999`
|
|
37
|
+
(Infinity) used to be accepted and broke the numbering of every event after it: a `longhaul_remember` write
|
|
38
|
+
was then stored without a seq and dropped, with no anomaly, when the transcript was read back.
|
|
39
|
+
- The load smoke (`npm run load-smoke`, a development check that is not part of the package) resolved a
|
|
40
|
+
wildcard `exports` target by replacing only its first `*`; it now replaces every one, as Node does (CodeQL
|
|
41
|
+
`js/incomplete-sanitization`). No pi package uses a target with two, so the smoke's results are unchanged.
|
|
42
|
+
|
|
11
43
|
## [v1.0.2] - 2026-10-13
|
|
12
44
|
|
|
13
45
|
### Changed
|
package/README.md
CHANGED
|
@@ -361,9 +361,9 @@ listed in [pi-compat.json](pi-compat.json). On a listed line it runs silently. O
|
|
|
361
361
|
per session and runs; a session that opted out of it (`PI_COMPACTOR=pi`) is not warned. If a compaction fails, pi's own compaction takes over. A nightly job tests against the
|
|
362
362
|
newest pi and opens an issue when it breaks.
|
|
363
363
|
|
|
364
|
-
Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0 and 1.
|
|
365
|
-
downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to
|
|
366
|
-
back, with a Longhaul compaction by each, and each must continue what the others wrote.
|
|
364
|
+
Tested with pi 0.87.1, 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0, 1.2.0 and 1.3.0.
|
|
365
|
+
Upgrading or downgrading pi mid-project is covered too: CI hands one transcript through every listed pi, oldest to
|
|
366
|
+
newest and back, with a Longhaul compaction by each, and each must continue what the others wrote.
|
|
367
367
|
|
|
368
368
|
## Known issues
|
|
369
369
|
|
package/docs/security.md
CHANGED
|
@@ -39,6 +39,11 @@ extension.
|
|
|
39
39
|
reading it into memory.
|
|
40
40
|
- A timestamp a `Date` cannot hold is dropped rather than thrown on.
|
|
41
41
|
- An id held by two entries (an old transcript can carry a re-minted id) resolves to the newest holder.
|
|
42
|
+
- A rule recorded again from the message that gave it (near-duplicate words) folds into that rule; a copy of a
|
|
43
|
+
lifted rule stays lifted, so restored span records cannot bring back a rule the user withdrew from a copy. A lift
|
|
44
|
+
lands on the rule holding its id now, and on one that held it before only when none does: the store judges the
|
|
45
|
+
lift against that same rule, and `longhaul_recall` marks lifted what the state lifted.
|
|
46
|
+
- A span or seq that is not a whole number from 1 (`1e999` parses as Infinity) makes the event malformed.
|
|
42
47
|
- An entry from a newer state format is refused by name, so a downgrade starts clean rather than misreading.
|
|
43
48
|
- Learned values read back from a transcript are checked again (length, printable ASCII, count). A name is
|
|
44
49
|
checked as when it was given and dropped if it fails, and a named value must still have a value's shape.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-longhaul",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Keep pi sessions working all night. A compactor that carries your session forward instead of rewriting it: your rules kept in your own words, the state written by code, old reasoning left behind, and about 5x faster than pi's own compaction on a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"extensions": [
|
|
33
33
|
"./src/index.ts"
|
|
34
34
|
],
|
|
35
|
-
"image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.
|
|
35
|
+
"image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.3/.github/assets/infographic.png"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"src/**/*.ts",
|
|
@@ -75,10 +75,10 @@
|
|
|
75
75
|
"typebox": "*"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
79
|
-
"@types/node": "^24.19.
|
|
78
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
79
|
+
"@types/node": "^24.19.2",
|
|
80
80
|
"typebox": "1.3.7",
|
|
81
|
-
"typescript": "
|
|
81
|
+
"typescript": "7.0.2"
|
|
82
82
|
},
|
|
83
83
|
"author": "Justin Barlow"
|
|
84
84
|
}
|
package/pi-compat.json
CHANGED
|
@@ -93,6 +93,15 @@
|
|
|
93
93
|
"npm run smoke and npm run cross-version -- --extra 1.2.0: every listed pi continues 1.2.0's transcripts and it continues theirs",
|
|
94
94
|
"one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
|
|
95
95
|
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"pi": "1.3.0",
|
|
99
|
+
"date": "2026-10-16",
|
|
100
|
+
"how": [
|
|
101
|
+
"npm test on the vendored pi (the version gate skipped): typecheck, unit suite, load smoke and the extension-API contract checks",
|
|
102
|
+
"npm run smoke and npm run cross-version -- --extra 1.3.0: every listed pi continues 1.3.0's transcripts and it continues theirs",
|
|
103
|
+
"one real compaction through pi --mode rpc on the setup-check fixture with a local model: a compaction record, no fallback"
|
|
104
|
+
]
|
|
96
105
|
}
|
|
97
106
|
]
|
|
98
107
|
}
|
package/src/store/events.ts
CHANGED
|
@@ -229,10 +229,13 @@ function isEventBody(event: Rec): boolean {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/** A span or seq: a whole number from 1, so a replayed `1e999` (Infinity) cannot stall the numbering after it. */
|
|
233
|
+
const isCount = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 1;
|
|
234
|
+
|
|
232
235
|
/** Shape check for replayed entries: a malformed one is skipped and counted by the fold, never thrown. */
|
|
233
236
|
export function isStateEvent(value: unknown): value is StateEvent {
|
|
234
237
|
if (!isRec(value)) return false;
|
|
235
|
-
const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") &&
|
|
238
|
+
const provenanceOk = (value.source === "remember" || value.source === "diff" || value.source === "pass") && isCount(value.span) && typeof value.at === "string" && isCount(value.seq);
|
|
236
239
|
return provenanceOk && isEventBody(value);
|
|
237
240
|
}
|
|
238
241
|
|
package/src/store/log.ts
CHANGED
|
@@ -39,6 +39,7 @@ interface Fold {
|
|
|
39
39
|
events: StateEvent[];
|
|
40
40
|
spans: SpanSummary[];
|
|
41
41
|
anomalies: FoldAnomaly[];
|
|
42
|
+
/** `span:seq` of every admitted event: a seq is unique within its span (see seqKey). */
|
|
42
43
|
seqs: Set<string>;
|
|
43
44
|
/** Loose events collected since the last compaction entry, keyed by nothing: in branch order. */
|
|
44
45
|
loose: StateEvent[];
|
|
@@ -54,6 +55,15 @@ function quoteOf(event: StateEvent): Quote[] {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The duplicate key of an event: its seq within its span. A pass numbers from the fold it reads, and a fold that
|
|
60
|
+
* lost span records (another extension's entry ahead of one) numbered later spans from too low a seq, so two
|
|
61
|
+
* spans' records can share seqs; each span's events are still its own.
|
|
62
|
+
*/
|
|
63
|
+
function seqKey(event: { span: number; seq: number }): string {
|
|
64
|
+
return `${event.span}:${event.seq}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
/** Admit one event into the fold, or record why not. */
|
|
58
68
|
function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker: QuoteChecker, into: StateEvent[]): void {
|
|
59
69
|
const newer = newerStateFormat(raw);
|
|
@@ -73,7 +83,7 @@ function admit(fold: Fold, raw: unknown, span: number, entryId: string, checker:
|
|
|
73
83
|
fold.anomalies.push({ span, entryId, reason: `event names span ${raw.span} while folding span ${span}: ${entryId}` });
|
|
74
84
|
return;
|
|
75
85
|
}
|
|
76
|
-
const key =
|
|
86
|
+
const key = seqKey(raw);
|
|
77
87
|
if (fold.seqs.has(key)) {
|
|
78
88
|
fold.anomalies.push({ span, entryId, reason: `duplicate seq: ${raw.seq} at ${entryId}` });
|
|
79
89
|
return;
|
|
@@ -131,7 +141,7 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
|
|
|
131
141
|
}
|
|
132
142
|
// The record SUPERSEDES the span's loose events, so release their seqs before admitting its own
|
|
133
143
|
// (otherwise the record's authoritative copy of a loose event is dropped as a duplicate seq).
|
|
134
|
-
for (const loose of fold.loose) fold.seqs.delete(
|
|
144
|
+
for (const loose of fold.loose) fold.seqs.delete(seqKey(loose));
|
|
135
145
|
const events: StateEvent[] = [];
|
|
136
146
|
for (const raw of record.events) admit(fold, raw, pending.span, entry.id, checker, events);
|
|
137
147
|
for (const event of events) fold.events.push(event);
|
|
@@ -140,13 +150,23 @@ function takeRecord(fold: Fold, entry: Extract<SessionEntry, { type: "custom" }>
|
|
|
140
150
|
fold.pending = undefined;
|
|
141
151
|
}
|
|
142
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Whether `entry` proves a pending compaction's span record never landed: a message, another compaction, or one
|
|
155
|
+
* of our own event entries. Anything else another extension wrote at session_compact (pi-goal's goal-state and
|
|
156
|
+
* goal-contract land there, ahead of the record) is passed over, so the record that follows still closes the span.
|
|
157
|
+
*/
|
|
158
|
+
function endsPendingSpan(entry: SessionEntry): boolean {
|
|
159
|
+
if (entry.type === "message" || entry.type === "compaction") return true;
|
|
160
|
+
return entry.type === "custom" && entry.customType === EVENT_ENTRY_TYPE;
|
|
161
|
+
}
|
|
162
|
+
|
|
143
163
|
export function readLog(branchEntries: SessionEntry[], checker: QuoteChecker): StateLog {
|
|
144
164
|
const fold: Fold = { events: [], spans: [], anomalies: [], seqs: new Set(), loose: [] };
|
|
145
165
|
let compactions = 0;
|
|
146
166
|
for (const entry of branchEntries) {
|
|
147
|
-
// A pending state compaction is closed by its span record,
|
|
148
|
-
//
|
|
149
|
-
if (fold.pending &&
|
|
167
|
+
// A pending state compaction is closed by its span record, the next entry of OURS after it; a message,
|
|
168
|
+
// compaction or event arriving first means the record never landed.
|
|
169
|
+
if (fold.pending && endsPendingSpan(entry)) synthesizeClose(fold, undefined, entry.timestamp);
|
|
150
170
|
if (entry.type === "compaction") {
|
|
151
171
|
compactions++;
|
|
152
172
|
const compactor = compactorOfEntry(entry);
|
package/src/store/project.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Retirement is a projection rule: the log never deletes, and recall reads the log.
|
|
7
7
|
*/
|
|
8
8
|
import type { Caps, ForeignCompactor, Now, Quote, Source, StateEvent, StateLog, World } from "./events.ts";
|
|
9
|
+
import { DEFAULT_NEAR_DUPLICATE_THRESHOLD, overlap, tokenize } from "./near-duplicate.ts";
|
|
9
10
|
import { oneLine } from "../one-line.ts";
|
|
10
11
|
|
|
11
12
|
export interface CorrectionItem {
|
|
@@ -17,6 +18,10 @@ export interface CorrectionItem {
|
|
|
17
18
|
seq: number;
|
|
18
19
|
liftedBy?: { messageId: string; ordinal: string; span: number };
|
|
19
20
|
retired: boolean;
|
|
21
|
+
/** Ids this rule held before being recorded again from its message (addCorrection); see liftTarget. */
|
|
22
|
+
formerIds?: Set<string>;
|
|
23
|
+
/** `span:seq` of the earlier correction.add events folded into this rule, for recall (addCorrection). */
|
|
24
|
+
copyKeys?: string[];
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface TextItem {
|
|
@@ -108,6 +113,70 @@ function applyClose(state: SessionState, event: Extract<StateEvent, { kind: "spa
|
|
|
108
113
|
consolidateWorkLog(state, caps);
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A rule recorded again from the message that gave it is the same rule: passes that lost their fold's span records
|
|
118
|
+
* were shown the message again and recorded it again under a new id, sometimes in other words, so a restored log
|
|
119
|
+
* holds one copy per such span. The rule takes the newest copy's id, span and place, and keeps its older ids for a lift
|
|
120
|
+
* naming one; a lifted rule stays lifted, as verify.ts keeps it for a new pass.
|
|
121
|
+
*/
|
|
122
|
+
/**
|
|
123
|
+
* The newest standing or lifted rule `quote` repeats: one from the same message whose words are a near-duplicate
|
|
124
|
+
* (near-duplicate.ts, at verify's default threshold; two rules one message gave passed that check against each
|
|
125
|
+
* other when recorded). A rule retired by the cap is not matched, so a later copy brings it back.
|
|
126
|
+
*/
|
|
127
|
+
function sameRuleIndex(corrections: CorrectionItem[], quote: Quote): number {
|
|
128
|
+
for (let index = corrections.length - 1; index >= 0; index--) {
|
|
129
|
+
const item = corrections[index];
|
|
130
|
+
if (item.retired && !item.liftedBy) continue;
|
|
131
|
+
if (isSameRule(item.quote, quote)) return index;
|
|
132
|
+
}
|
|
133
|
+
return -1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether two recorded rules are one: the same message, in the same or near-duplicate words. */
|
|
137
|
+
export function isSameRule(a: Quote, b: Quote): boolean {
|
|
138
|
+
if (a.messageId !== b.messageId) return false;
|
|
139
|
+
return a.text === b.text || overlap(tokenize(a.text), tokenize(b.text)) >= DEFAULT_NEAR_DUPLICATE_THRESHOLD;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** An event's key in the log: its seq is unique within its span (log.ts seqKey). */
|
|
143
|
+
export const eventKey = (event: { span: number; seq: number }): string => `${event.span}:${event.seq}`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The rule a lift naming `target` lands on, for verify.ts, which judges the lift against it, and for the
|
|
147
|
+
* projection, which applies it, so the two never differ: the newest holder of the id (an old transcript can carry
|
|
148
|
+
* a reminted one, security.md T1), and only when no rule holds it now, the newest that held it before being
|
|
149
|
+
* recorded again. A former id another rule holds now names that rule: the lifting pass saw only that one.
|
|
150
|
+
*/
|
|
151
|
+
export function liftTarget(corrections: CorrectionItem[], target: string): CorrectionItem | undefined {
|
|
152
|
+
return newestWhere(corrections, (item) => item.id === target) ?? newestWhere(corrections, (item) => item.formerIds?.has(target) ?? false);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function newestWhere(corrections: CorrectionItem[], match: (item: CorrectionItem) => boolean): CorrectionItem | undefined {
|
|
156
|
+
for (let index = corrections.length - 1; index >= 0; index--) if (match(corrections[index])) return corrections[index];
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function addCorrection(state: SessionState, event: Extract<StateEvent, { kind: "correction.add" }>, caps: Caps): void {
|
|
161
|
+
state.cursor.R = Math.max(state.cursor.R, idNumber(event.id));
|
|
162
|
+
const index = sameRuleIndex(state.corrections, event.quote);
|
|
163
|
+
const earlier = index < 0 ? undefined : state.corrections[index];
|
|
164
|
+
if (earlier?.liftedBy) {
|
|
165
|
+
(earlier.copyKeys ??= []).push(eventKey(event));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// The new item takes over the earlier one's lists, grown in place: copying them per copy would make N copies of
|
|
169
|
+
// one rule cost N² (security.md T4).
|
|
170
|
+
if (earlier) state.corrections.splice(index, 1);
|
|
171
|
+
const formerIds = earlier?.formerIds ?? new Set<string>();
|
|
172
|
+
if (earlier) formerIds.add(earlier.id);
|
|
173
|
+
formerIds.delete(event.id);
|
|
174
|
+
const copyKeys = earlier?.copyKeys ?? [];
|
|
175
|
+
if (earlier) copyKeys.push(eventKey(earlier));
|
|
176
|
+
state.corrections.push({ id: event.id, quote: event.quote, span: event.span, source: event.source, evidence: event.evidence, seq: event.seq, retired: false, ...(formerIds.size ? { formerIds } : {}), ...(copyKeys.length ? { copyKeys } : {}) });
|
|
177
|
+
state.retired.corrections += retireOverCap(state.corrections, caps.corrections);
|
|
178
|
+
}
|
|
179
|
+
|
|
111
180
|
export function applyEvent(previous: SessionState, event: StateEvent, caps: Caps): SessionState {
|
|
112
181
|
const state: SessionState = structuredClone(previous);
|
|
113
182
|
applyInPlace(state, event, caps);
|
|
@@ -116,7 +185,7 @@ export function applyEvent(previous: SessionState, event: StateEvent, caps: Caps
|
|
|
116
185
|
|
|
117
186
|
/** Fold one event into `state`, mutating it. The new state can share objects with the event. */
|
|
118
187
|
function applyInPlace(state: SessionState, event: StateEvent, caps: Caps): void {
|
|
119
|
-
state.seq = event.seq;
|
|
188
|
+
state.seq = Math.max(state.seq, event.seq);
|
|
120
189
|
switch (event.kind) {
|
|
121
190
|
case "goal.set":
|
|
122
191
|
state.goal = { quote: event.quote, setSpan: event.span };
|
|
@@ -131,15 +200,12 @@ function applyInPlace(state: SessionState, event: StateEvent, caps: Caps): void
|
|
|
131
200
|
if (state.objective) state.objective.doneWhen = event.quote;
|
|
132
201
|
break;
|
|
133
202
|
case "correction.add":
|
|
134
|
-
state
|
|
135
|
-
state.cursor.R = Math.max(state.cursor.R, idNumber(event.id));
|
|
136
|
-
state.retired.corrections += retireOverCap(state.corrections, caps.corrections);
|
|
203
|
+
addCorrection(state, event, caps);
|
|
137
204
|
break;
|
|
138
205
|
case "correction.lift": {
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
const target = [...state.corrections].reverse().find((item) => item.id === event.target);
|
|
206
|
+
// Older transcripts can hold two corrections with one id (a foreign close dropped the first, the cursor fell
|
|
207
|
+
// back and reminted it; the fold keeps both): liftTarget picks the one the lifting pass saw.
|
|
208
|
+
const target = liftTarget(state.corrections, event.target);
|
|
143
209
|
if (target) target.liftedBy = { ...event.by, span: event.span };
|
|
144
210
|
break;
|
|
145
211
|
}
|
package/src/store/recall.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { CommandRecord, ErrorRecord } from "../ledger/handoff.ts";
|
|
|
7
7
|
import type { Caps, StateEvent, StateLog } from "./events.ts";
|
|
8
8
|
import { orderedFiles } from "../files-render.ts";
|
|
9
9
|
import { oneLine as sanitize } from "../one-line.ts";
|
|
10
|
+
import { eventKey, project } from "./project.ts";
|
|
10
11
|
|
|
11
12
|
export type RecallWhat = "span" | "commands" | "errors" | "decisions" | "dead-ends" | "corrections" | "message" | "files" | "all";
|
|
12
13
|
|
|
@@ -39,28 +40,31 @@ export interface RecallHit {
|
|
|
39
40
|
export const RECALL_DEFAULT_LIMIT = 20;
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
/** One projection per recall call, however many kinds it lists (`all`). */
|
|
44
|
+
const liftedKeysCache = new WeakMap<RecallDeps, Set<string>>();
|
|
45
|
+
|
|
42
46
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* corrections with one id (security.md T1).
|
|
47
|
+
* `span:seq` of every correction.add the projection lifted, the copies folded into a lifted rule included, so recall
|
|
48
|
+
* shows a correction as lifted exactly when the state does (project.ts liftTarget, addCorrection).
|
|
46
49
|
*/
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
function liftedKeys(deps: RecallDeps): Set<string> {
|
|
51
|
+
const cached = liftedKeysCache.get(deps);
|
|
52
|
+
if (cached) return cached;
|
|
53
|
+
const keys = new Set<string>();
|
|
54
|
+
liftedKeysCache.set(deps, keys);
|
|
55
|
+
for (const item of project(deps.log, deps.caps).corrections) {
|
|
56
|
+
if (!item.liftedBy) continue;
|
|
57
|
+
keys.add(eventKey(item));
|
|
58
|
+
for (const key of item.copyKeys ?? []) keys.add(key);
|
|
55
59
|
}
|
|
56
|
-
return
|
|
60
|
+
return keys;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
function itemHits(events: StateEvent[], kind: "decisions" | "dead-ends" | "corrections", lifted: Set<
|
|
63
|
+
function itemHits(events: StateEvent[], kind: "decisions" | "dead-ends" | "corrections", lifted: Set<string>): RecallHit[] {
|
|
60
64
|
return events.flatMap((event): RecallHit[] => {
|
|
61
65
|
if (kind === "decisions" && event.kind === "decision.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} ${sanitize(event.text)}${event.evidence ? ` (${event.evidence})` : ""}` }];
|
|
62
66
|
if (kind === "dead-ends" && event.kind === "deadEnd.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} ${sanitize(event.text)}${event.evidence ? ` (${event.evidence})` : ""}` }];
|
|
63
|
-
if (kind === "corrections" && event.kind === "correction.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} [${event.quote.ordinal}] ${sanitize(event.quote.text)}`, lifted: lifted.has(event
|
|
67
|
+
if (kind === "corrections" && event.kind === "correction.add") return [{ kind, ref: event.id, span: event.span, text: `${event.id} [${event.quote.ordinal}] ${sanitize(event.quote.text)}`, lifted: lifted.has(eventKey(event)) }];
|
|
64
68
|
return [];
|
|
65
69
|
});
|
|
66
70
|
}
|
|
@@ -102,7 +106,7 @@ function spanHits(deps: RecallDeps, span: number): RecallHit[] {
|
|
|
102
106
|
const record = deps.log.spans.find((summary) => summary.span === span)?.record;
|
|
103
107
|
const commands = (record ? record.ledger.commands.map((c) => ({ ...c, span })) : allCommands(deps).filter((c) => c.span === span)).map(commandHit);
|
|
104
108
|
const errors = (record ? record.ledger.errors.map((e) => ({ ...e, span })) : allErrors(deps).filter((e) => e.span === span)).map(errorHit);
|
|
105
|
-
const lifted =
|
|
109
|
+
const lifted = liftedKeys(deps);
|
|
106
110
|
return [head, ...commands, ...errors, ...itemHits(events, "corrections", lifted), ...itemHits(events, "decisions", lifted), ...itemHits(events, "dead-ends", lifted)];
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -117,7 +121,7 @@ function hitsFor(what: RecallWhat, deps: RecallDeps, query: string | undefined):
|
|
|
117
121
|
case "span": return query && /^\d+$/.test(query) ? spanHits(deps, Number(query)) : spanHeads(deps);
|
|
118
122
|
case "commands": return allCommands(deps).map(commandHit);
|
|
119
123
|
case "errors": return allErrors(deps).map(errorHit);
|
|
120
|
-
case "decisions": case "dead-ends": case "corrections": return itemHits(deps.log.events, what,
|
|
124
|
+
case "decisions": case "dead-ends": case "corrections": return itemHits(deps.log.events, what, liftedKeys(deps));
|
|
121
125
|
case "message": {
|
|
122
126
|
const text = query ? deps.messageText(query) : undefined;
|
|
123
127
|
return text === undefined ? [] : [{ kind: "message", ref: query!, text: sanitize(text) }];
|
package/src/store/verify.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { ownRuleOf, withoutReplacedWords } from "./lift-replacement.ts";
|
|
|
10
10
|
import { liftNamesRule } from "./lift-words.ts";
|
|
11
11
|
import type { Caps, Now, Provenance, Quote, Rejection, StateEvent } from "./events.ts";
|
|
12
12
|
import { findNearDuplicate } from "./near-duplicate.ts";
|
|
13
|
-
import { activeCorrections, activeDeadEnds, activeDecisions, applyEvent, type SessionState } from "./project.ts";
|
|
13
|
+
import { activeCorrections, activeDeadEnds, activeDecisions, applyEvent, liftTarget, type SessionState } from "./project.ts";
|
|
14
14
|
|
|
15
15
|
export type Proposal =
|
|
16
16
|
| { kind: "objective"; ref?: string; text: string; doneWhen?: { ref?: string; text: string } }
|
|
@@ -110,10 +110,10 @@ function verifyQuoteKind(proposal: Extract<Proposal, { kind: "objective" | "done
|
|
|
110
110
|
return stamp(ctx, undefined, { kind: "correction.add", id: `R${ctx.state.cursor.R + 1}`, quote: resolved.quote });
|
|
111
111
|
}
|
|
112
112
|
case "lift": {
|
|
113
|
-
//
|
|
113
|
+
// The rule the projection will lift (project.ts liftTarget): an old transcript can carry a reminted id. A target
|
|
114
114
|
// written u<n> names the rule by the message that gave it: a rule labeled in this same pass has no id the model saw.
|
|
115
115
|
const byOrdinal = /^u\d+$/.test(proposal.target);
|
|
116
|
-
const named = [...ctx.state.corrections].reverse().find((item) =>
|
|
116
|
+
const named = byOrdinal ? [...ctx.state.corrections].reverse().find((item) => item.quote.ordinal === proposal.target && !item.retired) : liftTarget(ctx.state.corrections, proposal.target);
|
|
117
117
|
if (!named) return reject({ code: "unknown-target", target: proposal.target, known: activeCorrections(ctx.state).map((item) => item.id) });
|
|
118
118
|
const target = named;
|
|
119
119
|
if (target.liftedBy) {
|