@astrosheep/pi-context 0.24.0 → 0.25.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/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { NotesHome, NotesSnapshot } from "../notes/notes-snapshot.js";
|
|
2
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
|
|
3
|
+
|
|
4
|
+
/** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
|
|
5
|
+
function identityBlock(agentName: string, modelName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
6
|
+
const lines = [
|
|
7
|
+
`Agent name: ${agentName} (brain: ${modelName})`,
|
|
8
|
+
`First context window id: ${firstWindowId}`,
|
|
9
|
+
`Current context window id: ${currentWindowId}`,
|
|
10
|
+
];
|
|
11
|
+
if (previousWindowId) lines.push(`Previous context window id: ${previousWindowId}`);
|
|
12
|
+
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function relativeTime(timestamp: number, now: number): string {
|
|
16
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
17
|
+
const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
|
|
18
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
|
|
19
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
20
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rowsFor(snapshot: NotesSnapshot, scope: NotesHome["scope"]) {
|
|
24
|
+
return snapshot.homes.get(scope) ?? [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function notesUnavailableNotice(snapshot: NotesSnapshot): string | undefined {
|
|
28
|
+
if (snapshot.unavailable.length === 0) return undefined;
|
|
29
|
+
const homes = snapshot.unavailable.map((home) => home.label).join(", ");
|
|
30
|
+
return `Notes index incomplete: index for ${homes} unavailable during boot; notes_list can retry after recovery.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the human, project,
|
|
35
|
+
* own-agent, and current-model homes are all injected, broadest first; stale maps are skipped
|
|
36
|
+
* per home, and the session home is never peeked — a session MAP.md is an ordinary note. The
|
|
37
|
+
* pocket then lists recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT /
|
|
38
|
+
* POCKET_PROJECT_LIMIT / POCKET_HUMAN_LIMIT / POCKET_AGENT_LIMIT / POCKET_MODEL_LIMIT),
|
|
39
|
+
* most-recently-updated first within each home, one metadata line each: address, line count,
|
|
40
|
+
* UTF-8 byte count, relative update time at window open. Bodies never render
|
|
41
|
+
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
42
|
+
*/
|
|
43
|
+
function notesIndex(snapshot: NotesSnapshot): string {
|
|
44
|
+
const sections: string[] = [];
|
|
45
|
+
// Map residency ("地图在场"): scope-native maps, fresh ones injected broadest-first.
|
|
46
|
+
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
47
|
+
for (const scope of ["human", "project", "agent", "model"] as const) {
|
|
48
|
+
const toc = rowsFor(snapshot, scope).find((row) => row.path === "MAP.md");
|
|
49
|
+
if (toc && !toc.meta.stale) {
|
|
50
|
+
if (toc.body.length > 0) sections.push(toc.body);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
54
|
+
// churn from evicting the durable homes; maps never take pocket seats.
|
|
55
|
+
const recentNotes = [
|
|
56
|
+
...rowsFor(snapshot, "session").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
57
|
+
...rowsFor(snapshot, "project").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
58
|
+
...rowsFor(snapshot, "human").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_HUMAN_LIMIT),
|
|
59
|
+
...rowsFor(snapshot, "agent").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_AGENT_LIMIT),
|
|
60
|
+
...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
|
|
61
|
+
];
|
|
62
|
+
if (recentNotes.length > 0) {
|
|
63
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
|
|
64
|
+
for (const row of recentNotes) {
|
|
65
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
|
|
66
|
+
}
|
|
67
|
+
sections.push(lines.join("\n"));
|
|
68
|
+
}
|
|
69
|
+
return sections.join("\n\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notesHomeBlock(): string {
|
|
73
|
+
return "Note addresses: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own (current agent); @model/<vpath> is the current model's. @self and @model resolve to who is running now. Any other @ prefix, or @ inside a vpath, is a hard error; there is no fallback across prefixes. Anything not matching these is a plain file — use the file tools.";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a static, once-per-window boot block from explicit data. This function does not read
|
|
78
|
+
* notes or call runtime UI APIs; acquisition belongs to loadNotesSnapshot and its caller.
|
|
79
|
+
*/
|
|
80
|
+
export type BootRenderData = {
|
|
81
|
+
readonly agentName: string;
|
|
82
|
+
readonly modelName: string;
|
|
83
|
+
readonly firstWindowId: string;
|
|
84
|
+
readonly currentWindowId: string;
|
|
85
|
+
readonly previousWindowId?: string;
|
|
86
|
+
readonly notes: NotesSnapshot;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function renderBootBlock(data: BootRenderData): string {
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
|
|
92
|
+
parts.push(notesHomeBlock());
|
|
93
|
+
const incomplete = notesUnavailableNotice(data.notes);
|
|
94
|
+
if (incomplete) parts.push(incomplete);
|
|
95
|
+
const index = notesIndex(data.notes);
|
|
96
|
+
if (index) parts.push(index);
|
|
97
|
+
parts.push(PROTOCOL_BLOCK);
|
|
98
|
+
return parts.join("\n\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Codex-equivalent low-budget reminder. The measured remaining count is frozen into
|
|
103
|
+
* the text at the crossing that fires it, so each persisted copy is a snapshot true
|
|
104
|
+
* at write time; get_context_remaining remains the live source for the current figure.
|
|
105
|
+
*/
|
|
106
|
+
export function tokenBudgetGuidance(remaining: number): string {
|
|
107
|
+
return `${GUIDANCE_OPEN_TAG}\nYour brain is almost out of room — ${remaining} tokens left, and then your memory gets wiped. The wipe is automatic: there is no final turn to write then. Grab the notebook now — the goal, decisions, progress, learnings, next steps, the skills you still need, the window ID and item ID of every relevant user request still being solved, and important actions/tool calls for future reference. Replacing an older checkpoint? Mark it stale. Then call wipe_memory yourself — anything you do after the checkpoint isn't in it.\n${GUIDANCE_CLOSE_TAG}`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, SessionBoundaryDraft, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { BOOT_TYPE, CONTINUATION, CONTINUATION_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
|
|
4
|
+
import { currentWindowId, isWindowBootEntry, isWindowMarker, previousWindowId, type WindowMarker } from "./context-window.js";
|
|
5
|
+
import { buildBootMessage, sendBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
6
|
+
|
|
7
|
+
export type ResetTailState = { readonly boot: boolean; readonly continuation: boolean };
|
|
8
|
+
|
|
9
|
+
/** Match the hidden continuation entry that carries the one reset message. */
|
|
10
|
+
export function isWindowContinuationEntry(entry: SessionEntry): boolean {
|
|
11
|
+
return entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The single continuation sender: the only reset prose persisted for a window. */
|
|
15
|
+
export function sendContinuation(pi: ExtensionAPI): void {
|
|
16
|
+
pi.sendMessage(
|
|
17
|
+
{ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
18
|
+
{ triggerTurn: false },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The closed, ordered reset shape: marker, matching boot, continuation. This is the one
|
|
24
|
+
* source of the reset message and the one place that mints the new window identity.
|
|
25
|
+
*/
|
|
26
|
+
export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
|
|
27
|
+
const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
28
|
+
const usedWindowIds = new Set(
|
|
29
|
+
ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
|
|
30
|
+
);
|
|
31
|
+
let windowId: string;
|
|
32
|
+
do {
|
|
33
|
+
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
34
|
+
} while (usedWindowIds.has(windowId));
|
|
35
|
+
const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
|
|
36
|
+
return [
|
|
37
|
+
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
38
|
+
{ type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
|
|
39
|
+
{ type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
40
|
+
] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Inspect the persisted tail of a reset marker. It reports which reset messages are present
|
|
60
|
+
* only while the tail stays repairable: metadata may follow the marker, but real conversation,
|
|
61
|
+
* a foreign message, a later marker, or a misordered/duplicate reset artifact refuses repair.
|
|
62
|
+
*/
|
|
63
|
+
export function inspectResetTail(ctx: ExtensionContext, markerId: string, windowId: string): ResetTailState | undefined {
|
|
64
|
+
const branch = ctx.sessionManager.getBranch();
|
|
65
|
+
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
66
|
+
if (markerIndex < 0) return undefined;
|
|
67
|
+
let boot = false;
|
|
68
|
+
let continuation = false;
|
|
69
|
+
for (const entry of branch.slice(markerIndex + 1)) {
|
|
70
|
+
if (isWindowBootEntry(entry, windowId)) {
|
|
71
|
+
// The boot is unique and must precede the continuation; a repeat or a late
|
|
72
|
+
// boot would move the provider boundary or misorder the reset shape.
|
|
73
|
+
if (boot || continuation) return undefined;
|
|
74
|
+
boot = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (isWindowContinuationEntry(entry)) {
|
|
78
|
+
if (continuation || !boot) return undefined;
|
|
79
|
+
continuation = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isWindowMarker(entry) || entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary") {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { boot, continuation };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True once the marker's tail already carries its boot and continuation in a valid order. */
|
|
90
|
+
export function resetTailCommitted(ctx: ExtensionContext, markerId: string, windowId: string): boolean {
|
|
91
|
+
const tail = inspectResetTail(ctx, markerId, windowId);
|
|
92
|
+
return tail?.boot === true && tail.continuation === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
|
|
96
|
+
export function repairResetTail(pi: ExtensionAPI, ctx: ExtensionContext, marker: WindowMarker, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
|
|
97
|
+
const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
|
|
98
|
+
if (!tail || (tail.boot && tail.continuation)) return;
|
|
99
|
+
if (!tail.boot) sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
|
|
100
|
+
if (!tail.continuation) sendContinuation(pi);
|
|
101
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { AgentBeforeSettleEvent, ExtensionAPI, ExtensionContext, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { currentReset } from "./context-window.js";
|
|
5
|
+
|
|
6
|
+
type BudgetOwner = {
|
|
7
|
+
automaticResetEnabled: (ctx: ExtensionContext) => boolean;
|
|
8
|
+
resetDue: (ctx: ExtensionContext) => boolean;
|
|
9
|
+
consumeTurnEnd: (ctx: ExtensionContext) => SessionBoundaryDraft[];
|
|
10
|
+
clear: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ResetOptions = {
|
|
14
|
+
isEnabled: () => boolean;
|
|
15
|
+
budget: BudgetOwner;
|
|
16
|
+
buildReset: (ctx: ExtensionContext) => SessionBoundaryDraft[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isAbort(message: AgentMessage, outcome: string | undefined, ctx: ExtensionContext): boolean {
|
|
20
|
+
return outcome === "aborted" || (message.role === "assistant" && message.stopReason === "aborted") || ctx.signal?.aborted === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
|
|
24
|
+
if (message.role !== "assistant") return false;
|
|
25
|
+
return isContextOverflow(message, ctx.model?.contextWindow) ||
|
|
26
|
+
(ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reset-control state. The concern is split into two independent, explicitly typed axes so
|
|
31
|
+
* that no combination of independently combinable lifecycle booleans is legal by accident:
|
|
32
|
+
*
|
|
33
|
+
* - `request` is the single pending explicit wipe request ("none" or "explicit"). Repeated
|
|
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";
|
|
46
|
+
export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
|
|
47
|
+
|
|
48
|
+
export interface ResetControlState {
|
|
49
|
+
readonly request: ResetRequestPhase;
|
|
50
|
+
readonly overflow: ResetOverflowPhase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function initialResetControl(): ResetControlState {
|
|
54
|
+
return { request: "none", overflow: "idle" };
|
|
55
|
+
}
|
|
56
|
+
|
|
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
|
+
*/
|
|
62
|
+
export interface ResetTurnEndFacts {
|
|
63
|
+
readonly aborted: boolean;
|
|
64
|
+
readonly overflow: boolean;
|
|
65
|
+
readonly failed: boolean;
|
|
66
|
+
readonly enabled: boolean;
|
|
67
|
+
readonly queued: boolean;
|
|
68
|
+
readonly automaticResetEnabled: boolean;
|
|
69
|
+
readonly thresholdDue: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
|
|
73
|
+
export interface ResetBeforeSettleFacts {
|
|
74
|
+
readonly queued: boolean;
|
|
75
|
+
readonly enabled: boolean;
|
|
76
|
+
readonly automaticResetEnabled: boolean;
|
|
77
|
+
readonly aborted: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ResetControlEvent =
|
|
81
|
+
| { readonly type: "request" }
|
|
82
|
+
| { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
|
|
83
|
+
| { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
|
|
84
|
+
| { readonly type: "settled" }
|
|
85
|
+
| { readonly type: "abort" }
|
|
86
|
+
| { readonly type: "clear" };
|
|
87
|
+
|
|
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
|
+
export type ResetControlEffect =
|
|
99
|
+
| "none"
|
|
100
|
+
| "requested"
|
|
101
|
+
| "already-requested"
|
|
102
|
+
| "commit-boundary"
|
|
103
|
+
| "recover-overflow";
|
|
104
|
+
|
|
105
|
+
export interface ResetControlResult {
|
|
106
|
+
readonly state: ResetControlState;
|
|
107
|
+
readonly effect: ResetControlEffect;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pure reset-control transition. It performs no writes, no policy resolution of its own, and
|
|
112
|
+
* no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
|
|
113
|
+
* full transition table without a live Pi session.
|
|
114
|
+
*/
|
|
115
|
+
export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case "request": {
|
|
118
|
+
if (state.request === "explicit") return { state, effect: "already-requested" };
|
|
119
|
+
return { state: { ...state, request: "explicit" }, effect: "requested" };
|
|
120
|
+
}
|
|
121
|
+
case "turn_end": {
|
|
122
|
+
const facts = event.facts;
|
|
123
|
+
const requested = state.request === "explicit";
|
|
124
|
+
// The explicit request is consumed by the turn boundary whether or not it commits.
|
|
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
|
+
}
|
|
130
|
+
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
|
+
const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
|
|
134
|
+
const spent = state.overflow === "pending-spent" || state.overflow === "spent";
|
|
135
|
+
const overflow: ResetOverflowPhase = pending
|
|
136
|
+
? (spent ? "pending-spent" : "pending")
|
|
137
|
+
: (spent ? "spent" : "idle");
|
|
138
|
+
return { state: { request, overflow }, effect: "none" };
|
|
139
|
+
}
|
|
140
|
+
// Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
|
|
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" };
|
|
147
|
+
}
|
|
148
|
+
case "before_settle": {
|
|
149
|
+
const facts = event.facts;
|
|
150
|
+
if (state.overflow !== "pending" && state.overflow !== "pending-spent") return { state, effect: "none" };
|
|
151
|
+
// Let a queued turn run first; its own turn_end settles or clears this chain.
|
|
152
|
+
if (facts.queued) return { state, effect: "none" };
|
|
153
|
+
const spent = state.overflow === "pending-spent";
|
|
154
|
+
if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
|
|
155
|
+
return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
|
|
156
|
+
}
|
|
157
|
+
// The recovery is one-use per failure chain. Committing it spends the attempt.
|
|
158
|
+
return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
|
|
159
|
+
}
|
|
160
|
+
case "settled":
|
|
161
|
+
// Settlement ends the failure chain but leaves a pending explicit request armed.
|
|
162
|
+
return { state: { ...state, overflow: "idle" }, effect: "none" };
|
|
163
|
+
case "abort":
|
|
164
|
+
case "clear":
|
|
165
|
+
return { state: initialResetControl(), effect: "none" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
|
|
171
|
+
* compaction summaries: turn_end commits explicit/threshold resets after a complete tool
|
|
172
|
+
* batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
|
|
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`.
|
|
178
|
+
*/
|
|
179
|
+
export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
|
|
180
|
+
let sessionActive = true;
|
|
181
|
+
let control = initialResetControl();
|
|
182
|
+
|
|
183
|
+
const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
|
|
184
|
+
const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
|
|
185
|
+
try {
|
|
186
|
+
return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
|
|
187
|
+
} 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
|
+
return entries.length > 0 ? { entries } : undefined;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
pi.on("turn_end", (event, ctx) => {
|
|
197
|
+
if (!sessionActive) return undefined;
|
|
198
|
+
const aborted = isAbort(event.message, event.outcome, ctx);
|
|
199
|
+
const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
|
|
200
|
+
// Lifecycle owns whether drafts are acceptable for this turn. Budget only
|
|
201
|
+
// drains its instance-local staging, so aborts and disabled mode cannot commit it.
|
|
202
|
+
const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
|
|
203
|
+
const entries = [...(event.entries ?? []), ...budgetEntries];
|
|
204
|
+
const decision = reduceResetControl(control, {
|
|
205
|
+
type: "turn_end",
|
|
206
|
+
facts: {
|
|
207
|
+
aborted,
|
|
208
|
+
overflow: aborted ? false : isOverflowLike(event.message, ctx),
|
|
209
|
+
failed: event.outcome === "error",
|
|
210
|
+
enabled: options.isEnabled(),
|
|
211
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
212
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
213
|
+
get thresholdDue() { return options.budget.resetDue(ctx); },
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
control = decision.state;
|
|
217
|
+
if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
|
|
218
|
+
return entries.length > 0 ? { entries } : undefined;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
|
|
222
|
+
if (!sessionActive) return undefined;
|
|
223
|
+
const decision = reduceResetControl(control, {
|
|
224
|
+
type: "before_settle",
|
|
225
|
+
facts: {
|
|
226
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
227
|
+
get enabled() { return options.isEnabled(); },
|
|
228
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
229
|
+
get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
control = decision.state;
|
|
233
|
+
if (decision.effect !== "recover-overflow") return undefined;
|
|
234
|
+
return resetBoundaryResult(event.entries, ctx);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
238
|
+
if (!sessionActive) return undefined;
|
|
239
|
+
if (event.signal.aborted) return { cancel: true };
|
|
240
|
+
const markerExists = currentReset(ctx) !== undefined;
|
|
241
|
+
if (options.isEnabled() || markerExists) {
|
|
242
|
+
if (event.reason === "manual") {
|
|
243
|
+
ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
|
|
244
|
+
}
|
|
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
|
+
return { cancel: true };
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
253
|
+
if (ctx.signal?.aborted) clear();
|
|
254
|
+
});
|
|
255
|
+
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
|
+
control = reduceResetControl(control, { type: "settled" }).state;
|
|
259
|
+
});
|
|
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; });
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
request() {
|
|
266
|
+
const decision = reduceResetControl(control, { type: "request" });
|
|
267
|
+
control = decision.state;
|
|
268
|
+
return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
|
|
269
|
+
},
|
|
270
|
+
clear,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { registerBudget } from "./budget.js";
|
|
4
|
+
import { output } from "../tool-output.js";
|
|
5
|
+
import { migrateLegacyHomes } from "../notes/paths.js";
|
|
6
|
+
import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
|
|
7
|
+
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
8
|
+
import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
|
|
9
|
+
import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
10
|
+
|
|
11
|
+
declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
|
|
12
|
+
|
|
13
|
+
// The bundle captures its identity; direct source loads must not claim a built hash.
|
|
14
|
+
const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
|
|
15
|
+
? "unbundled source (build unknown)"
|
|
16
|
+
: `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
|
|
17
|
+
|
|
18
|
+
function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean {
|
|
19
|
+
return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Register the context-window runtime and its context-owned commands/tools. */
|
|
23
|
+
export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
|
|
24
|
+
let enabled = true;
|
|
25
|
+
let missingBootNotice: string | undefined;
|
|
26
|
+
const incompleteNotesNotified = new Set<string>();
|
|
27
|
+
const pendingResetNotices = new Set<string>();
|
|
28
|
+
// Announce only a fully committed reset (marker + matching boot + continuation), not a
|
|
29
|
+
// reset request or a partial boot repair.
|
|
30
|
+
const notifyCommittedResets = (ctx: ExtensionContext, addedWindowId?: string) => {
|
|
31
|
+
if (addedWindowId) pendingResetNotices.add(addedWindowId);
|
|
32
|
+
if (pendingResetNotices.size === 0) return;
|
|
33
|
+
const branch = ctx.sessionManager.getBranch();
|
|
34
|
+
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
|
+
pendingResetNotices.delete(windowId);
|
|
38
|
+
ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
|
|
42
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
43
|
+
notifyCommittedResets(ctx);
|
|
44
|
+
pendingResetNotices.clear();
|
|
45
|
+
});
|
|
46
|
+
const notifyIncompleteNotes: IncompleteNotesNotifier = (ctx, windowId, snapshot) => {
|
|
47
|
+
if (snapshot.unavailable.length === 0 || incompleteNotesNotified.has(windowId)) return;
|
|
48
|
+
incompleteNotesNotified.add(windowId);
|
|
49
|
+
const homes = snapshot.unavailable.map((home) => home.label).join(", ");
|
|
50
|
+
ctx.ui.notify(`pi-context: notes index incomplete for ${homes}; notes_list can retry after recovery.`, "warning");
|
|
51
|
+
};
|
|
52
|
+
const migrationWarning = migrateLegacyHomes();
|
|
53
|
+
if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
|
|
54
|
+
|
|
55
|
+
const budget = registerBudget(pi, () => enabled, settingsManager);
|
|
56
|
+
|
|
57
|
+
pi.on("session_start", (_event, ctx) => {
|
|
58
|
+
if (!enabled) return;
|
|
59
|
+
missingBootNotice = undefined;
|
|
60
|
+
pendingResetNotices.clear();
|
|
61
|
+
ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
62
|
+
});
|
|
63
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
64
|
+
missingBootNotice = undefined;
|
|
65
|
+
pendingResetNotices.clear();
|
|
66
|
+
if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
|
|
70
|
+
// let a summary of a reset branch smuggle erased history back into the destination.
|
|
71
|
+
pi.on("session_before_tree", (event, ctx) => {
|
|
72
|
+
if (!event.preparation.userWantsSummary) return undefined;
|
|
73
|
+
if (!branchHasWindowMarker(ctx) && !branchHasWindowMarker(ctx, event.preparation.targetId)) return undefined;
|
|
74
|
+
ctx.ui.notify("pi-context: skipped branch summary across a reset window; navigation continues without erased history.", "info");
|
|
75
|
+
return { summary: { summary: "" } };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// This is the final provider-facing projection. Reset windows cut at their matching boot;
|
|
79
|
+
// root windows only refresh a forked boot identity and retain the copied root transcript.
|
|
80
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
81
|
+
const reset = currentReset(ctx);
|
|
82
|
+
const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
|
|
83
|
+
try {
|
|
84
|
+
return { messages: reset ? projectWindow(event.messages, windowId) : projectRootWindow(event.messages, windowId) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (missingBootNotice !== windowId) {
|
|
87
|
+
missingBootNotice = windowId;
|
|
88
|
+
ctx.ui.notify(`pi-context: active context window ${windowId} has no visible boot; request cancelled safely. Use /wipe-memory to start another window.`, "error");
|
|
89
|
+
}
|
|
90
|
+
ctx.abort();
|
|
91
|
+
const safeHead = getCurrentSystemMessage(event.messages);
|
|
92
|
+
return { messages: safeHead ? [safeHead] : [] };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.registerCommand("pi-context", {
|
|
97
|
+
description: "Show loaded version/build and toggle pi-context context windows",
|
|
98
|
+
getArgumentCompletions: (prefix) =>
|
|
99
|
+
["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
|
|
100
|
+
handler: async (args, cmdCtx) => {
|
|
101
|
+
const arg = args.trim().toLowerCase();
|
|
102
|
+
if (arg === "on") {
|
|
103
|
+
enabled = true;
|
|
104
|
+
ensureBoot(pi, cmdCtx, notifyIncompleteNotes);
|
|
105
|
+
} else if (arg === "off") {
|
|
106
|
+
enabled = false;
|
|
107
|
+
budget.clear();
|
|
108
|
+
resets.clear();
|
|
109
|
+
} else if (arg !== "") {
|
|
110
|
+
cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"} · ${buildLabel} · Pi ${VERSION}`, "info");
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.registerCommand("wipe-memory", {
|
|
118
|
+
description: "Persist a fresh context window without calling the model",
|
|
119
|
+
handler: async (_args, cmdCtx) => {
|
|
120
|
+
if (!enabled) {
|
|
121
|
+
cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await cmdCtx.waitForIdle();
|
|
125
|
+
if (!enabled) return;
|
|
126
|
+
resets.clear();
|
|
127
|
+
notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.registerTool(defineTool({
|
|
132
|
+
name: "wipe_memory",
|
|
133
|
+
label: "Wipe memory",
|
|
134
|
+
description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
|
|
135
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
136
|
+
async execute() {
|
|
137
|
+
if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
|
|
138
|
+
return output({ status: resets.request() }, undefined, true);
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const resets = registerResetLifecycle(pi, {
|
|
143
|
+
isEnabled: () => enabled,
|
|
144
|
+
buildReset: (ctx) => {
|
|
145
|
+
const drafts = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
146
|
+
pendingResetNotices.add(drafts[1].details.windowId);
|
|
147
|
+
return drafts;
|
|
148
|
+
},
|
|
149
|
+
budget,
|
|
150
|
+
});
|
|
151
|
+
}
|