@astrosheep/pi-context 0.25.1 → 0.26.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/README.md +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +616 -370
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +19 -15
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +298 -242
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/tool-output.d.ts +101 -0
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +318 -19
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +55 -29
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +8 -7
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- package/dist/test/helpers/extension.d.ts +115 -0
- package/dist/test/helpers/extension.js +6 -6
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +111 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +22 -24
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +137 -7
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +25 -17
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +308 -244
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- package/src/protocol.ts +5 -6
|
@@ -20,11 +20,17 @@ export function sendContinuation(pi: ExtensionAPI): void {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The closed, ordered reset shape: marker, matching boot,
|
|
24
|
-
*
|
|
23
|
+
* The closed, ordered reset shape: retain-none native checkpoint, marker, matching boot,
|
|
24
|
+
* continuation. The boot snapshot is acquired asynchronously after capturing this identity.
|
|
25
25
|
*/
|
|
26
|
-
export function buildResetDrafts(
|
|
27
|
-
|
|
26
|
+
export async function buildResetDrafts(
|
|
27
|
+
ctx: ExtensionContext,
|
|
28
|
+
notifyIncompleteNotes?: IncompleteNotesNotifier,
|
|
29
|
+
isCurrent: () => boolean = () => true,
|
|
30
|
+
): Promise<[SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft]> {
|
|
31
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
32
|
+
const previousId = currentWindowId(ctx);
|
|
33
|
+
const sessionPrefix = sessionId.slice(0, 8);
|
|
28
34
|
const usedWindowIds = new Set(
|
|
29
35
|
ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
|
|
30
36
|
);
|
|
@@ -32,27 +38,13 @@ export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?:
|
|
|
32
38
|
do {
|
|
33
39
|
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
34
40
|
} while (usedWindowIds.has(windowId));
|
|
35
|
-
const boot = buildBootMessage(ctx, windowId,
|
|
41
|
+
const boot = await buildBootMessage(ctx, windowId, previousId, notifyIncompleteNotes, isCurrent);
|
|
36
42
|
return [
|
|
43
|
+
{ type: "compaction", summary: "", firstKeptEntryId: null },
|
|
37
44
|
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
38
45
|
{ type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
|
|
39
46
|
{ type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
40
|
-
]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Persist the marker and send both hidden reset messages; returns the new window id. */
|
|
44
|
-
export function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
|
|
45
|
-
const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
46
|
-
pi.appendEntry(marker.customType, marker.data);
|
|
47
|
-
pi.sendMessage(
|
|
48
|
-
{ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
|
|
49
|
-
{ triggerTurn: false },
|
|
50
|
-
);
|
|
51
|
-
pi.sendMessage(
|
|
52
|
-
{ customType: continuation.customType, content: continuation.content, display: continuation.display },
|
|
53
|
-
{ triggerTurn: false },
|
|
54
|
-
);
|
|
55
|
-
return boot.details.windowId;
|
|
47
|
+
];
|
|
56
48
|
}
|
|
57
49
|
|
|
58
50
|
/**
|
|
@@ -93,9 +85,19 @@ export function resetTailCommitted(ctx: ExtensionContext, markerId: string, wind
|
|
|
93
85
|
}
|
|
94
86
|
|
|
95
87
|
/** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
|
|
96
|
-
export function repairResetTail(
|
|
88
|
+
export async function repairResetTail(
|
|
89
|
+
pi: ExtensionAPI,
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
marker: WindowMarker,
|
|
92
|
+
notifyIncompleteNotes?: IncompleteNotesNotifier,
|
|
93
|
+
isCurrent: () => boolean = () => true,
|
|
94
|
+
): Promise<void> {
|
|
97
95
|
const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
|
|
98
96
|
if (!tail || (tail.boot && tail.continuation)) return;
|
|
99
|
-
|
|
100
|
-
if (!
|
|
97
|
+
const boot = tail.boot ? undefined : await buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes, isCurrent);
|
|
98
|
+
if (!isCurrent()) return;
|
|
99
|
+
const latest = inspectResetTail(ctx, marker.id, marker.data.windowId);
|
|
100
|
+
if (!latest) return;
|
|
101
|
+
if (!latest.boot && boot) sendBoot(pi, boot);
|
|
102
|
+
if (!latest.continuation && (latest.boot || boot)) sendContinuation(pi);
|
|
101
103
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { AgentBeforeSettleEvent, ExtensionAPI, ExtensionContext, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { currentReset } from "./context-window.js";
|
|
4
|
+
import { currentReset, currentWindowId } from "./context-window.js";
|
|
5
5
|
|
|
6
6
|
type BudgetOwner = {
|
|
7
7
|
automaticResetEnabled: (ctx: ExtensionContext) => boolean;
|
|
8
|
-
|
|
8
|
+
hardReserveDue: (ctx: ExtensionContext) => boolean;
|
|
9
9
|
consumeTurnEnd: (ctx: ExtensionContext) => SessionBoundaryDraft[];
|
|
10
10
|
clear: () => void;
|
|
11
11
|
};
|
|
@@ -13,7 +13,9 @@ type BudgetOwner = {
|
|
|
13
13
|
type ResetOptions = {
|
|
14
14
|
isEnabled: () => boolean;
|
|
15
15
|
budget: BudgetOwner;
|
|
16
|
-
buildReset: (ctx: ExtensionContext) => SessionBoundaryDraft[]
|
|
16
|
+
buildReset: (ctx: ExtensionContext, isCurrent: () => boolean) => SessionBoundaryDraft[] | Promise<SessionBoundaryDraft[]>;
|
|
17
|
+
getLifecycleGeneration?: () => number;
|
|
18
|
+
onResetReady?: (ctx: ExtensionContext, drafts: readonly SessionBoundaryDraft[]) => void;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
function isAbort(message: AgentMessage, outcome: string | undefined, ctx: ExtensionContext): boolean {
|
|
@@ -26,79 +28,59 @@ function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
|
|
|
26
28
|
(ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* requests deduplicate while one is pending.
|
|
35
|
-
* - `overflow` is the bounded provider-overflow recovery chain:
|
|
36
|
-
* - "idle" no overflow failure is pending; recovery is available for a later chain.
|
|
37
|
-
* - "pending" an overflow failure is pending; recovery is still available.
|
|
38
|
-
* - "pending-spent" an overflow failure is pending, but recovery was already spent this chain.
|
|
39
|
-
* - "spent" recovery was spent and the failure is no longer pending.
|
|
40
|
-
*
|
|
41
|
-
* All eight combinations of the two axes are reachable (a request may arrive while an overflow
|
|
42
|
-
* chain is pending, spent, or settled), and each has a defined transition. There is no state
|
|
43
|
-
* whose fields silently contradict one another.
|
|
44
|
-
*/
|
|
45
|
-
export type ResetRequestPhase = "none" | "explicit";
|
|
31
|
+
export type ResetRequestSource = "manual" | "automatic";
|
|
32
|
+
export type ResetRequest =
|
|
33
|
+
| { readonly phase: "none" }
|
|
34
|
+
| { readonly phase: "close-out"; readonly windowId: string; readonly source: ResetRequestSource }
|
|
35
|
+
| { readonly phase: "tool-requested"; readonly windowId: string };
|
|
46
36
|
export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
|
|
47
37
|
|
|
48
38
|
export interface ResetControlState {
|
|
49
|
-
readonly request:
|
|
39
|
+
readonly request: ResetRequest;
|
|
50
40
|
readonly overflow: ResetOverflowPhase;
|
|
51
41
|
}
|
|
52
42
|
|
|
43
|
+
const NO_REQUEST: ResetRequest = { phase: "none" };
|
|
44
|
+
|
|
53
45
|
export function initialResetControl(): ResetControlState {
|
|
54
|
-
return { request:
|
|
46
|
+
return { request: NO_REQUEST, overflow: "idle" };
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
/**
|
|
58
|
-
* Facts for a completed turn. The three `get`-supplied fields are guards the adapter resolves
|
|
59
|
-
* lazily: the reducer reads each one only in the branch where the previous adapter consulted it,
|
|
60
|
-
* so policy resolution and pending-message probes keep their original call ordering.
|
|
61
|
-
*/
|
|
49
|
+
/** Facts for a completed turn. Guard values stay lazy to preserve policy resolution order. */
|
|
62
50
|
export interface ResetTurnEndFacts {
|
|
51
|
+
readonly windowId: string;
|
|
63
52
|
readonly aborted: boolean;
|
|
64
53
|
readonly overflow: boolean;
|
|
65
54
|
readonly failed: boolean;
|
|
66
55
|
readonly enabled: boolean;
|
|
67
56
|
readonly queued: boolean;
|
|
68
57
|
readonly automaticResetEnabled: boolean;
|
|
69
|
-
readonly
|
|
58
|
+
readonly hardReserveDue: boolean;
|
|
70
59
|
}
|
|
71
60
|
|
|
72
61
|
/** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
|
|
73
62
|
export interface ResetBeforeSettleFacts {
|
|
63
|
+
readonly windowId: string;
|
|
74
64
|
readonly queued: boolean;
|
|
75
65
|
readonly enabled: boolean;
|
|
76
66
|
readonly automaticResetEnabled: boolean;
|
|
77
67
|
readonly aborted: boolean;
|
|
68
|
+
readonly failed: boolean;
|
|
78
69
|
}
|
|
79
70
|
|
|
80
71
|
export type ResetControlEvent =
|
|
81
|
-
| { readonly type: "
|
|
72
|
+
| { readonly type: "close_out"; readonly windowId: string; readonly source: ResetRequestSource }
|
|
73
|
+
| { readonly type: "tool_request"; readonly windowId: string }
|
|
82
74
|
| { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
|
|
83
75
|
| { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
|
|
84
76
|
| { readonly type: "settled" }
|
|
85
77
|
| { readonly type: "abort" }
|
|
86
78
|
| { readonly type: "clear" };
|
|
87
79
|
|
|
88
|
-
/**
|
|
89
|
-
* The requested effect of a transition. Effects are named, not performed: marker/boot/
|
|
90
|
-
* continuation writes, continuation requests, and UI notices stay in the adapter.
|
|
91
|
-
*
|
|
92
|
-
* - "none" keep any return value to the drafts already collected for this event.
|
|
93
|
-
* - "requested" a new explicit reset request was recorded.
|
|
94
|
-
* - "already-requested" a request was already pending and was deduplicated.
|
|
95
|
-
* - "commit-boundary" build and commit the reset boundary now (turn_end).
|
|
96
|
-
* - "recover-overflow" build and commit the one bounded overflow recovery now (settle).
|
|
97
|
-
*/
|
|
98
80
|
export type ResetControlEffect =
|
|
99
81
|
| "none"
|
|
100
|
-
| "
|
|
101
|
-
| "already-
|
|
82
|
+
| "close-out-armed"
|
|
83
|
+
| "already-pending"
|
|
102
84
|
| "commit-boundary"
|
|
103
85
|
| "recover-overflow";
|
|
104
86
|
|
|
@@ -107,59 +89,77 @@ export interface ResetControlResult {
|
|
|
107
89
|
readonly effect: ResetControlEffect;
|
|
108
90
|
}
|
|
109
91
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*/
|
|
92
|
+
function requestForWindow(request: ResetRequest, windowId: string): ResetRequest {
|
|
93
|
+
return request.phase !== "none" && request.windowId === windowId ? request : NO_REQUEST;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pure reset-control transitions: request phases own close-out, tool commit, and fallback. */
|
|
115
97
|
export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
|
|
116
98
|
switch (event.type) {
|
|
117
|
-
case "
|
|
118
|
-
|
|
119
|
-
|
|
99
|
+
case "close_out": {
|
|
100
|
+
const request = state.request;
|
|
101
|
+
if (request.phase === "tool-requested" && request.windowId === event.windowId) {
|
|
102
|
+
return { state, effect: "already-pending" };
|
|
103
|
+
}
|
|
104
|
+
if (request.phase === "close-out" && request.windowId === event.windowId) {
|
|
105
|
+
if (request.source === "manual" || event.source === "automatic") return { state, effect: "already-pending" };
|
|
106
|
+
return { state: { ...state, request: { ...request, source: "manual" } }, effect: "close-out-armed" };
|
|
107
|
+
}
|
|
108
|
+
return { state: { ...state, request: { phase: "close-out", windowId: event.windowId, source: event.source } }, effect: "close-out-armed" };
|
|
109
|
+
}
|
|
110
|
+
case "tool_request": {
|
|
111
|
+
const request = state.request;
|
|
112
|
+
if (request.phase === "tool-requested" && request.windowId === event.windowId) return { state, effect: "already-pending" };
|
|
113
|
+
return { state: { ...state, request: { phase: "tool-requested", windowId: event.windowId } }, effect: "close-out-armed" };
|
|
120
114
|
}
|
|
121
115
|
case "turn_end": {
|
|
122
116
|
const facts = event.facts;
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const request: ResetRequestPhase = "none";
|
|
126
|
-
if (facts.aborted) {
|
|
127
|
-
// An aborted turn drops the whole boundary: no explicit request, no overflow chain.
|
|
128
|
-
return { state: { request, overflow: "idle" }, effect: "none" };
|
|
129
|
-
}
|
|
117
|
+
const request = requestForWindow(state.request, facts.windowId);
|
|
118
|
+
if (facts.aborted) return { state: initialResetControl(), effect: "none" };
|
|
130
119
|
if (facts.overflow) {
|
|
131
|
-
// A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
|
|
132
|
-
// A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
|
|
133
120
|
const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
|
|
134
121
|
const spent = state.overflow === "pending-spent" || state.overflow === "spent";
|
|
135
122
|
const overflow: ResetOverflowPhase = pending
|
|
136
123
|
? (spent ? "pending-spent" : "pending")
|
|
137
124
|
: (spent ? "spent" : "idle");
|
|
138
|
-
return { state: { request, overflow }, effect: "none" };
|
|
125
|
+
return { state: { request: NO_REQUEST, overflow }, effect: "none" };
|
|
126
|
+
}
|
|
127
|
+
if (facts.failed) return { state: { request: NO_REQUEST, overflow: state.overflow }, effect: "none" };
|
|
128
|
+
if (!facts.enabled) {
|
|
129
|
+
return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "none" };
|
|
130
|
+
}
|
|
131
|
+
// A direct tool request commits after the whole tool batch. The budget cutoff is
|
|
132
|
+
// a separate hard-reserve safety path; ordinary close-out waits for settlement.
|
|
133
|
+
if (request.phase === "tool-requested" || facts.hardReserveDue) {
|
|
134
|
+
return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
|
|
139
135
|
}
|
|
140
|
-
|
|
141
|
-
// error leaves the armed overflow chain untouched for the settle boundary.
|
|
142
|
-
const overflow: ResetOverflowPhase = facts.failed ? state.overflow : "idle";
|
|
143
|
-
if (!facts.enabled || facts.failed) return { state: { request, overflow }, effect: "none" };
|
|
144
|
-
// Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
|
|
145
|
-
const commit = requested || facts.thresholdDue;
|
|
146
|
-
return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
|
|
136
|
+
return { state: { request, overflow: "idle" }, effect: "none" };
|
|
147
137
|
}
|
|
148
138
|
case "before_settle": {
|
|
149
139
|
const facts = event.facts;
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
140
|
+
if (facts.aborted) return { state: initialResetControl(), effect: "none" };
|
|
141
|
+
const request = requestForWindow(state.request, facts.windowId);
|
|
142
|
+
if (state.overflow === "pending" || state.overflow === "pending-spent") {
|
|
143
|
+
if (facts.queued) {
|
|
144
|
+
return { state: { ...state, request: facts.failed ? NO_REQUEST : request }, effect: "none" };
|
|
145
|
+
}
|
|
146
|
+
const spent = state.overflow === "pending-spent";
|
|
147
|
+
if (!facts.enabled || !facts.automaticResetEnabled) {
|
|
148
|
+
return { state: { request: facts.failed ? NO_REQUEST : request, overflow: spent ? "spent" : "idle" }, effect: "none" };
|
|
149
|
+
}
|
|
150
|
+
return { state: { request: NO_REQUEST, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
|
|
156
151
|
}
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
if (facts.failed || !facts.enabled || request.phase !== "close-out") {
|
|
153
|
+
return { state: { ...state, request: NO_REQUEST }, effect: "none" };
|
|
154
|
+
}
|
|
155
|
+
if (facts.queued) return { state: { ...state, request }, effect: "none" };
|
|
156
|
+
if (request.source === "automatic" && !facts.automaticResetEnabled) {
|
|
157
|
+
return { state: { ...state, request: NO_REQUEST }, effect: "none" };
|
|
158
|
+
}
|
|
159
|
+
return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
|
|
159
160
|
}
|
|
160
161
|
case "settled":
|
|
161
|
-
|
|
162
|
-
return { state: { ...state, overflow: "idle" }, effect: "none" };
|
|
162
|
+
return { state: initialResetControl(), effect: "none" };
|
|
163
163
|
case "abort":
|
|
164
164
|
case "clear":
|
|
165
165
|
return { state: initialResetControl(), effect: "none" };
|
|
@@ -167,71 +167,76 @@ export function reduceResetControl(state: ResetControlState, event: ResetControl
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* Own
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* native recovery attempt has been cancelled.
|
|
174
|
-
*
|
|
175
|
-
* This function is the effect adapter: it captures Pi events, translates them into pure
|
|
176
|
-
* reset-control transitions, and performs the resulting marker/boot/continuation writes and
|
|
177
|
-
* notifications. The decision of what to do lives entirely in `reduceResetControl`.
|
|
170
|
+
* Own close-out requests at Pi's public turn and pre-settlement boundaries. Tool-requested
|
|
171
|
+
* resets commit at turn_end; manual and budget close-outs remain armed across note/tool turns
|
|
172
|
+
* and fall back at a successful agent_before_settle. Overflow recovery remains bounded.
|
|
178
173
|
*/
|
|
179
174
|
export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
|
|
180
175
|
let sessionActive = true;
|
|
176
|
+
let lifecycleGeneration = 0;
|
|
181
177
|
let control = initialResetControl();
|
|
182
178
|
|
|
183
179
|
const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
|
|
184
|
-
const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
|
|
180
|
+
const resetBoundaryResult = async (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
|
|
181
|
+
const generation = lifecycleGeneration;
|
|
182
|
+
const outerGeneration = options.getLifecycleGeneration?.();
|
|
183
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
184
|
+
const windowId = currentWindowId(ctx);
|
|
185
|
+
const isCurrent = () => sessionActive && lifecycleGeneration === generation &&
|
|
186
|
+
(outerGeneration === undefined || options.getLifecycleGeneration?.() === outerGeneration) && options.isEnabled() &&
|
|
187
|
+
ctx.signal?.aborted !== true && ctx.sessionManager.getSessionId() === sessionId && currentWindowId(ctx) === windowId;
|
|
188
|
+
let resetDrafts: SessionBoundaryDraft[];
|
|
185
189
|
try {
|
|
186
|
-
|
|
190
|
+
resetDrafts = await options.buildReset(ctx, isCurrent);
|
|
187
191
|
} catch (error) {
|
|
188
|
-
ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
189
|
-
// The incoming drafts and budget drafts are already valid work from this
|
|
190
|
-
// boundary. Preserve them, but do not claim a continuation when reset
|
|
191
|
-
// construction failed.
|
|
192
|
+
if (isCurrent()) ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
192
193
|
return entries.length > 0 ? { entries } : undefined;
|
|
193
194
|
}
|
|
195
|
+
if (!isCurrent()) return entries.length > 0 ? { entries } : undefined;
|
|
196
|
+
options.onResetReady?.(ctx, resetDrafts);
|
|
197
|
+
return { entries: [...entries, ...resetDrafts], continue: true as const };
|
|
194
198
|
};
|
|
195
199
|
|
|
196
|
-
pi.on("turn_end", (event, ctx) => {
|
|
200
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
197
201
|
if (!sessionActive) return undefined;
|
|
198
202
|
const aborted = isAbort(event.message, event.outcome, ctx);
|
|
199
203
|
const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
|
|
200
|
-
|
|
201
|
-
// drains its instance-local staging, so aborts and disabled mode cannot commit it.
|
|
202
|
-
const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
|
|
204
|
+
const budgetEntries = options.isEnabled() && !aborted && event.outcome !== "error" ? stagedBudgetEntries : [];
|
|
203
205
|
const entries = [...(event.entries ?? []), ...budgetEntries];
|
|
204
206
|
const decision = reduceResetControl(control, {
|
|
205
207
|
type: "turn_end",
|
|
206
208
|
facts: {
|
|
209
|
+
windowId: currentWindowId(ctx),
|
|
207
210
|
aborted,
|
|
208
211
|
overflow: aborted ? false : isOverflowLike(event.message, ctx),
|
|
209
212
|
failed: event.outcome === "error",
|
|
210
213
|
enabled: options.isEnabled(),
|
|
211
214
|
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
212
215
|
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
213
|
-
get
|
|
216
|
+
get hardReserveDue() { return options.budget.hardReserveDue(ctx); },
|
|
214
217
|
},
|
|
215
218
|
});
|
|
216
219
|
control = decision.state;
|
|
217
|
-
if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
|
|
220
|
+
if (decision.effect === "commit-boundary") return await resetBoundaryResult(entries, ctx);
|
|
218
221
|
return entries.length > 0 ? { entries } : undefined;
|
|
219
222
|
});
|
|
220
223
|
|
|
221
|
-
pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
|
|
224
|
+
pi.on("agent_before_settle", async (event: AgentBeforeSettleEvent, ctx) => {
|
|
222
225
|
if (!sessionActive) return undefined;
|
|
223
226
|
const decision = reduceResetControl(control, {
|
|
224
227
|
type: "before_settle",
|
|
225
228
|
facts: {
|
|
229
|
+
windowId: currentWindowId(ctx),
|
|
226
230
|
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
227
231
|
get enabled() { return options.isEnabled(); },
|
|
228
232
|
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
229
|
-
|
|
233
|
+
aborted: event.outcome === "aborted" || ctx.signal?.aborted === true,
|
|
234
|
+
failed: event.outcome === "error",
|
|
230
235
|
},
|
|
231
236
|
});
|
|
232
237
|
control = decision.state;
|
|
233
|
-
if (decision.effect !== "recover-overflow") return undefined;
|
|
234
|
-
return resetBoundaryResult(event.entries, ctx);
|
|
238
|
+
if (decision.effect !== "commit-boundary" && decision.effect !== "recover-overflow") return undefined;
|
|
239
|
+
return await resetBoundaryResult(event.entries, ctx);
|
|
235
240
|
});
|
|
236
241
|
|
|
237
242
|
pi.on("session_before_compact", (event, ctx) => {
|
|
@@ -242,30 +247,31 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
|
|
|
242
247
|
if (event.reason === "manual") {
|
|
243
248
|
ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
|
|
244
249
|
}
|
|
245
|
-
// Native compaction is cancelled here. Threshold resets are decided solely from
|
|
246
|
-
// completed-turn usage at turn_end, never from canonical pre-request history.
|
|
247
250
|
return { cancel: true };
|
|
248
251
|
}
|
|
249
252
|
return undefined;
|
|
250
253
|
});
|
|
251
254
|
|
|
252
255
|
pi.on("agent_end", (_event, ctx) => {
|
|
253
|
-
if (ctx.signal?.aborted)
|
|
256
|
+
if (ctx.signal?.aborted) control = reduceResetControl(control, { type: "abort" }).state;
|
|
254
257
|
});
|
|
255
258
|
pi.on("agent_settled", () => {
|
|
256
|
-
// A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
|
|
257
|
-
// user prompt starts a new chain; successful continuations clear this earlier.
|
|
258
259
|
control = reduceResetControl(control, { type: "settled" }).state;
|
|
259
260
|
});
|
|
260
|
-
pi.on("session_start", () => { clear(); sessionActive = true; });
|
|
261
|
-
pi.on("session_tree", clear);
|
|
262
|
-
pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
|
|
261
|
+
pi.on("session_start", () => { lifecycleGeneration++; clear(); sessionActive = true; });
|
|
262
|
+
pi.on("session_tree", () => { lifecycleGeneration++; clear(); });
|
|
263
|
+
pi.on("session_shutdown", () => { lifecycleGeneration++; clear(); options.budget.clear(); sessionActive = false; });
|
|
263
264
|
|
|
264
265
|
return {
|
|
265
|
-
|
|
266
|
-
const decision = reduceResetControl(control, { type: "
|
|
266
|
+
closeOut(windowId: string, source: ResetRequestSource) {
|
|
267
|
+
const decision = reduceResetControl(control, { type: "close_out", windowId, source });
|
|
268
|
+
control = decision.state;
|
|
269
|
+
return decision.effect;
|
|
270
|
+
},
|
|
271
|
+
request(windowId: string) {
|
|
272
|
+
const decision = reduceResetControl(control, { type: "tool_request", windowId });
|
|
267
273
|
control = decision.state;
|
|
268
|
-
return decision.effect === "already-
|
|
274
|
+
return decision.effect === "already-pending" ? "rollover_already_pending" : "rollover_requested";
|
|
269
275
|
},
|
|
270
276
|
clear,
|
|
271
277
|
};
|
package/src/context/runtime.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { registerBudget } from "./budget.js";
|
|
4
4
|
import { output } from "../tool-output.js";
|
|
5
|
-
import { migrateLegacyHomes } from "../notes/
|
|
6
|
-
import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
|
|
5
|
+
import { migrateLegacyHomes } from "../pi/notes/adapter.js";
|
|
6
|
+
import { currentReset, currentWindowId, isCheckpointBackedReset, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId, type WindowMarker } from "./context-window.js";
|
|
7
7
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
8
|
-
import { buildResetDrafts,
|
|
8
|
+
import { buildResetDrafts, resetTailCommitted } from "./reset-artifacts.js";
|
|
9
|
+
import { BOOT_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
|
|
9
10
|
import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
10
11
|
|
|
11
12
|
declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
|
|
@@ -22,6 +23,7 @@ function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean
|
|
|
22
23
|
/** Register the context-window runtime and its context-owned commands/tools. */
|
|
23
24
|
export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
|
|
24
25
|
let enabled = true;
|
|
26
|
+
let lifecycleGeneration = 0;
|
|
25
27
|
let missingBootNotice: string | undefined;
|
|
26
28
|
const incompleteNotesNotified = new Set<string>();
|
|
27
29
|
const pendingResetNotices = new Set<string>();
|
|
@@ -32,8 +34,8 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
32
34
|
if (pendingResetNotices.size === 0) return;
|
|
33
35
|
const branch = ctx.sessionManager.getBranch();
|
|
34
36
|
for (const windowId of pendingResetNotices) {
|
|
35
|
-
const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
36
|
-
if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
|
|
37
|
+
const marker = branch.find((entry): entry is WindowMarker => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
38
|
+
if (!marker || !isCheckpointBackedReset(ctx, marker) || !resetTailCommitted(ctx, marker.id, windowId)) continue;
|
|
37
39
|
pendingResetNotices.delete(windowId);
|
|
38
40
|
ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
|
|
39
41
|
}
|
|
@@ -52,18 +54,24 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
52
54
|
const migrationWarning = migrateLegacyHomes();
|
|
53
55
|
if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
|
|
54
56
|
|
|
55
|
-
const budget = registerBudget(pi, () => enabled, settingsManager);
|
|
57
|
+
const budget = registerBudget(pi, () => enabled, settingsManager, (windowId) => resets.closeOut(windowId, "automatic"));
|
|
56
58
|
|
|
57
|
-
pi.on("session_start", (_event, ctx) => {
|
|
59
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
60
|
+
const generation = ++lifecycleGeneration;
|
|
58
61
|
if (!enabled) return;
|
|
59
62
|
missingBootNotice = undefined;
|
|
60
63
|
pendingResetNotices.clear();
|
|
61
|
-
ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
64
|
+
await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
62
65
|
});
|
|
63
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
66
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
67
|
+
const generation = ++lifecycleGeneration;
|
|
64
68
|
missingBootNotice = undefined;
|
|
65
69
|
pendingResetNotices.clear();
|
|
66
|
-
if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
70
|
+
if (enabled) await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
71
|
+
});
|
|
72
|
+
pi.on("session_shutdown", () => {
|
|
73
|
+
lifecycleGeneration++;
|
|
74
|
+
pendingResetNotices.clear();
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
// Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
|
|
@@ -81,7 +89,11 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
81
89
|
const reset = currentReset(ctx);
|
|
82
90
|
const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
|
|
83
91
|
try {
|
|
84
|
-
|
|
92
|
+
if (reset) {
|
|
93
|
+
if (!event.messages.some((message) => isWindowBoot(message, windowId))) throw new Error(`Missing boot for context window ${windowId}`);
|
|
94
|
+
return { messages: isCheckpointBackedReset(ctx, reset) ? event.messages : projectWindow(event.messages, windowId) };
|
|
95
|
+
}
|
|
96
|
+
return { messages: projectRootWindow(event.messages, windowId) };
|
|
85
97
|
} catch (error) {
|
|
86
98
|
if (missingBootNotice !== windowId) {
|
|
87
99
|
missingBootNotice = windowId;
|
|
@@ -101,9 +113,12 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
101
113
|
const arg = args.trim().toLowerCase();
|
|
102
114
|
if (arg === "on") {
|
|
103
115
|
enabled = true;
|
|
104
|
-
|
|
116
|
+
const generation = ++lifecycleGeneration;
|
|
117
|
+
await ensureBoot(pi, cmdCtx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
105
118
|
} else if (arg === "off") {
|
|
106
119
|
enabled = false;
|
|
120
|
+
lifecycleGeneration++;
|
|
121
|
+
pendingResetNotices.clear();
|
|
107
122
|
budget.clear();
|
|
108
123
|
resets.clear();
|
|
109
124
|
} else if (arg !== "") {
|
|
@@ -115,16 +130,25 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
115
130
|
});
|
|
116
131
|
|
|
117
132
|
pi.registerCommand("wipe-memory", {
|
|
118
|
-
description: "
|
|
133
|
+
description: "Ask the agent to close out its notes, then start a fresh context window",
|
|
119
134
|
handler: async (_args, cmdCtx) => {
|
|
120
135
|
if (!enabled) {
|
|
121
136
|
cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
|
|
122
137
|
return;
|
|
123
138
|
}
|
|
139
|
+
const requestedWindowId = currentWindowId(cmdCtx);
|
|
140
|
+
await cmdCtx.waitForIdle();
|
|
141
|
+
if (!enabled || currentWindowId(cmdCtx) !== requestedWindowId) return;
|
|
142
|
+
const armed = resets.closeOut(requestedWindowId, "manual");
|
|
143
|
+
if (armed === "already-pending") return;
|
|
144
|
+
try {
|
|
145
|
+
pi.sendMessage({ customType: WARNING_TYPE, content: WARNING_CONTENT, display: false }, { triggerTurn: true });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
resets.clear();
|
|
148
|
+
cmdCtx.ui.notify(`pi-context: could not start manual close-out (${String(error)}).`, "error");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
124
151
|
await cmdCtx.waitForIdle();
|
|
125
|
-
if (!enabled) return;
|
|
126
|
-
resets.clear();
|
|
127
|
-
notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
|
|
128
152
|
},
|
|
129
153
|
});
|
|
130
154
|
|
|
@@ -133,18 +157,22 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
133
157
|
label: "Wipe memory",
|
|
134
158
|
description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
|
|
135
159
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
136
|
-
async execute() {
|
|
160
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
137
161
|
if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
|
|
138
|
-
return output({ status: resets.request() }, undefined, true);
|
|
162
|
+
return output({ status: resets.request(currentWindowId(ctx)) }, undefined, true);
|
|
139
163
|
},
|
|
140
164
|
}));
|
|
141
165
|
|
|
142
166
|
const resets = registerResetLifecycle(pi, {
|
|
143
167
|
isEnabled: () => enabled,
|
|
144
|
-
buildReset: (ctx) =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
buildReset: (ctx, isCurrent) => buildResetDrafts(ctx, notifyIncompleteNotes, isCurrent),
|
|
169
|
+
getLifecycleGeneration: () => lifecycleGeneration,
|
|
170
|
+
onResetReady: (_ctx, drafts) => {
|
|
171
|
+
const boot = drafts.find((draft) => draft.type === "custom_message" && draft.customType === BOOT_TYPE);
|
|
172
|
+
if (boot?.type === "custom_message" && boot.details && typeof boot.details === "object") {
|
|
173
|
+
const windowId = (boot.details as { windowId?: unknown }).windowId;
|
|
174
|
+
if (typeof windowId === "string") pendingResetNotices.add(windowId);
|
|
175
|
+
}
|
|
148
176
|
},
|
|
149
177
|
budget,
|
|
150
178
|
});
|
|
@@ -24,7 +24,7 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextSettin
|
|
|
24
24
|
else {
|
|
25
25
|
const parsed = validMargin(margins.reminderMarginTokens);
|
|
26
26
|
if (parsed === undefined) {
|
|
27
|
-
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default
|
|
27
|
+
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using the default reminder margin.`);
|
|
28
28
|
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
29
29
|
} else reminderMargin = parsed;
|
|
30
30
|
}
|
package/src/dream/cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult,
|
|
|
8
8
|
import { gitCommit } from "./git.js";
|
|
9
9
|
import { readDreamerSettings, type DreamerSetting } from "./settings.js";
|
|
10
10
|
import { doctor } from "./doctor.js";
|
|
11
|
-
import { notesRoot } from "../notes/
|
|
11
|
+
import { notesRoot } from "../pi/notes/adapter.js";
|
|
12
12
|
|
|
13
13
|
function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
|
|
14
14
|
function packageRoot(): string {
|
package/src/dream/doctor.ts
CHANGED
|
@@ -27,10 +27,13 @@ export function doctor(home: string): string[] {
|
|
|
27
27
|
if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
|
|
28
28
|
fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
|
|
29
29
|
}
|
|
30
|
-
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/,
|
|
30
|
+
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
|
|
31
31
|
if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
32
32
|
}
|
|
33
|
-
for (const
|
|
33
|
+
for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
|
|
34
|
+
if (fields.has(legacy)) report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
|
|
35
|
+
}
|
|
36
|
+
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
34
37
|
const value = fields.get(key);
|
|
35
38
|
if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
|
36
39
|
}
|