@astrosheep/pi-context 0.22.1 → 0.23.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 +1 -1
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/history-tools.js +3 -4
- package/dist/src/notes/store.js +14 -8
- package/dist/src/notes/tools.js +14 -21
- package/dist/src/reset-lifecycle.js +82 -28
- package/dist/src/tool-output.js +8 -11
- package/dist/test/agent-loop.test.js +25 -9
- package/dist/test/coherence.test.js +32 -36
- package/dist/test/dream.test.js +23 -3
- package/dist/test/integration.test.js +26 -27
- package/dist/test/notes.test.js +106 -20
- package/dist/test/pagination.property.test.js +24 -29
- package/dist/test/reset-lifecycle.test.js +99 -102
- package/docs/reset-lifecycle.md +4 -2
- package/package.json +1 -1
- package/playbook.md +2 -2
- package/src/dream/cli.ts +1 -1
- package/src/history-tools.ts +3 -4
- package/src/notes/store.ts +16 -9
- package/src/notes/tools.ts +16 -23
- package/src/reset-lifecycle.ts +89 -28
- package/src/tool-output.ts +8 -11
package/src/notes/tools.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { localIso } from "./model.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, readWindowBlock, withinTextBudget } from "../tool-output.js";
|
|
5
5
|
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
6
|
import { assertAddress } from "./address.js";
|
|
7
|
-
import {
|
|
7
|
+
import { type Origin } from "./frontmatter.js";
|
|
8
8
|
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
9
9
|
|
|
10
10
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
@@ -12,10 +12,6 @@ const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("sel
|
|
|
12
12
|
}));
|
|
13
13
|
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
14
14
|
|
|
15
|
-
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
16
|
-
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
function failure(error: unknown) {
|
|
20
16
|
if (error instanceof NoteError) {
|
|
21
17
|
const payload: Record<string, unknown> = { error: error.message };
|
|
@@ -35,28 +31,28 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
35
31
|
const content = params.content;
|
|
36
32
|
try {
|
|
37
33
|
const destination = assertAddress(params.address);
|
|
38
|
-
|
|
39
|
-
return output({ address: params.address,
|
|
34
|
+
writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
35
|
+
return output({ address: params.address, written: true });
|
|
40
36
|
} catch (error) { return failure(error); }
|
|
41
37
|
},
|
|
42
38
|
}));
|
|
43
39
|
|
|
44
40
|
pi.registerTool(defineTool({
|
|
45
41
|
name: "notes_edit", label: "Notes edit",
|
|
46
|
-
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries
|
|
42
|
+
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries the address and a diff of what changed.`,
|
|
47
43
|
parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
|
|
48
44
|
async execute(_id, params, _signal, _update, ctx) {
|
|
49
45
|
try {
|
|
50
46
|
const destination = assertAddress(params.address);
|
|
51
|
-
const {
|
|
52
|
-
return output({ address: params.address, applied,
|
|
47
|
+
const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
|
|
48
|
+
return output({ address: params.address, applied, diff });
|
|
53
49
|
} catch (error) { return failure(error); }
|
|
54
50
|
},
|
|
55
51
|
}));
|
|
56
52
|
|
|
57
53
|
pi.registerTool(defineTool({
|
|
58
54
|
name: "notes_read", label: "Notes read",
|
|
59
|
-
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window
|
|
55
|
+
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window in the shared READ WINDOW block: concatenate only the content after the block to reconstruct the note.`,
|
|
60
56
|
parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
|
|
61
57
|
async execute(_id, params, _signal, _update, ctx) {
|
|
62
58
|
let note: ReturnType<typeof readNote>;
|
|
@@ -65,27 +61,24 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
65
61
|
note = readNote(ctx, destination.path, destination.scope);
|
|
66
62
|
} catch (error) { return failure(error); }
|
|
67
63
|
if (!note) return output({ error: "note not found", address: params.address });
|
|
68
|
-
const text =
|
|
64
|
+
const text = note.text;
|
|
69
65
|
const totalChars = Array.from(text).length;
|
|
70
66
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
71
|
-
const created_at = localIso(note.meta.created_at);
|
|
72
|
-
const updated_at = localIso(note.meta.updated_at);
|
|
73
|
-
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
74
67
|
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
75
68
|
const { content, ...rest } = window;
|
|
76
|
-
return outputRaw(
|
|
69
|
+
return outputRaw(readWindowBlock([["address", params.address]], window), content, { address: params.address, ...rest });
|
|
77
70
|
}, (result) => withinTextBudget(result.content[0].text));
|
|
78
71
|
},
|
|
79
72
|
}));
|
|
80
73
|
|
|
81
74
|
pi.registerTool(defineTool({
|
|
82
75
|
name: "notes_list", label: "Notes list",
|
|
83
|
-
description: `List note files as rows carrying address,
|
|
76
|
+
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
|
|
84
77
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
85
78
|
async execute(_id, params, _signal, _update, ctx) {
|
|
86
79
|
let rows: ReturnType<typeof listNotes>;
|
|
87
80
|
try { rows = listNotes(ctx, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
88
|
-
const files: Array<{ address: string;
|
|
81
|
+
const files: Array<{ address: string; stale: boolean; updated_at: string; address_truncated?: boolean }> = rows.map((row) => ({ address: row.address, stale: row.meta.stale, updated_at: localIso(row.meta.updated_at) }));
|
|
89
82
|
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
90
83
|
if (fits(file)) return file;
|
|
91
84
|
const address = middleTruncate(file.address, (candidate) => fits({ ...file, address: candidate, address_truncated: true }));
|
|
@@ -96,16 +89,16 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
96
89
|
|
|
97
90
|
pi.registerTool(defineTool({
|
|
98
91
|
name: "notes_search", label: "Notes search",
|
|
99
|
-
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address
|
|
92
|
+
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
|
|
100
93
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
101
94
|
async execute(_id, params, _signal, _update, ctx) {
|
|
102
95
|
const queries = searchQueries(params.query);
|
|
103
96
|
let rows: ReturnType<typeof searchNotes>;
|
|
104
97
|
try { rows = searchNotes(ctx, queries, { pattern: params.pattern ?? undefined }); } catch (error) { return failure(error); }
|
|
105
98
|
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
106
|
-
const result: Array<{ address: string;
|
|
107
|
-
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false,
|
|
108
|
-
return { address: row.address,
|
|
99
|
+
const result: Array<{ address: string; updated_at: string; stale: boolean; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; offset_chars: number }>; address_truncated?: boolean }> = rows.map((row) => {
|
|
100
|
+
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, offset_chars: match.offsetChars }));
|
|
101
|
+
return { address: row.address, updated_at: localIso(row.meta.updated_at), stale: row.meta.stale, matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
109
102
|
});
|
|
110
103
|
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
111
104
|
if (fits(file)) return file;
|
package/src/reset-lifecycle.ts
CHANGED
|
@@ -12,7 +12,16 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
12
12
|
isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
|
|
13
13
|
onReset: (entryId: string) => void;
|
|
14
14
|
}) {
|
|
15
|
-
type Attempt = {
|
|
15
|
+
type Attempt = {
|
|
16
|
+
completed: boolean;
|
|
17
|
+
explicit: boolean;
|
|
18
|
+
nextRequested: boolean;
|
|
19
|
+
continuationStarted: boolean;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
settled: boolean;
|
|
22
|
+
wait: Promise<void>;
|
|
23
|
+
release: () => void;
|
|
24
|
+
};
|
|
16
25
|
type Request =
|
|
17
26
|
| { phase: "idle" }
|
|
18
27
|
| { phase: "requested" }
|
|
@@ -21,38 +30,39 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
21
30
|
let handledEntry: string | undefined;
|
|
22
31
|
let active = true;
|
|
23
32
|
|
|
33
|
+
const release = (attempt: Attempt) => {
|
|
34
|
+
if (attempt.settled) return;
|
|
35
|
+
attempt.settled = true;
|
|
36
|
+
if (state.phase === "compacting" && state.attempt === attempt) {
|
|
37
|
+
state = { phase: "idle" };
|
|
38
|
+
handledEntry = undefined;
|
|
39
|
+
}
|
|
40
|
+
attempt.release();
|
|
41
|
+
};
|
|
24
42
|
const clear = () => {
|
|
43
|
+
if (state.phase === "compacting") release(state.attempt);
|
|
25
44
|
state = { phase: "idle" };
|
|
26
45
|
handledEntry = undefined;
|
|
27
46
|
};
|
|
28
47
|
const valid = (request: Attempt, ctx: ExtensionContext) =>
|
|
29
48
|
active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
47
|
-
if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
|
|
48
|
-
if (state.phase !== "requested") return;
|
|
49
|
-
// One owner for requested resets. Consume the request before any external call;
|
|
50
|
-
// repeated settled events and reentrant callbacks are harmless.
|
|
51
|
-
const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
|
|
50
|
+
const begin = (ctx: ExtensionContext) => {
|
|
51
|
+
let releaseWait!: () => void;
|
|
52
|
+
const request: Attempt = {
|
|
53
|
+
completed: false,
|
|
54
|
+
explicit: true,
|
|
55
|
+
nextRequested: false,
|
|
56
|
+
continuationStarted: false,
|
|
57
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
58
|
+
settled: false,
|
|
59
|
+
wait: new Promise<void>((resolve) => { releaseWait = resolve; }),
|
|
60
|
+
release: () => releaseWait(),
|
|
61
|
+
};
|
|
52
62
|
state = { phase: "compacting", attempt: request };
|
|
53
63
|
const onError = (error: Error) => {
|
|
54
64
|
if (!valid(request, ctx)) return;
|
|
55
|
-
|
|
65
|
+
release(request);
|
|
56
66
|
// Do not retry from settled in a tight loop. A later prompt may trigger a
|
|
57
67
|
// native reset or explicitly request one.
|
|
58
68
|
ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
|
|
@@ -61,19 +71,64 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
61
71
|
ctx.compact({
|
|
62
72
|
onComplete: () => {
|
|
63
73
|
if (!valid(request, ctx)) return;
|
|
64
|
-
state = { phase: "idle" };
|
|
65
74
|
// session_compact only confirms the boundary. onComplete runs after
|
|
66
75
|
// Pi clears compaction state; sending inside the hook starts too early.
|
|
67
76
|
// A queued user prompt may already have started at compaction_end.
|
|
68
77
|
if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
69
|
-
|
|
78
|
+
// The SDK detaches sendMessage, so own the next settled event before
|
|
79
|
+
// starting it. The originating agent_settled handler awaits wait.
|
|
80
|
+
if (request.continuationStarted) return;
|
|
81
|
+
request.continuationStarted = true;
|
|
82
|
+
try {
|
|
83
|
+
pi.sendMessage(options.continuation, { triggerTurn: true });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
70
88
|
}
|
|
89
|
+
release(request);
|
|
71
90
|
},
|
|
72
91
|
onError,
|
|
73
92
|
});
|
|
74
93
|
} catch (error) {
|
|
75
94
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
76
95
|
}
|
|
96
|
+
return request;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// State is intentionally not resumed from a pending request: a loaded session must
|
|
100
|
+
// not execute work from a tool that belonged to a previous runtime or tree branch.
|
|
101
|
+
pi.on("session_start", () => { clear(); active = true; });
|
|
102
|
+
pi.on("session_shutdown", () => { clear(); active = false; });
|
|
103
|
+
pi.on("session_tree", clear);
|
|
104
|
+
|
|
105
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
106
|
+
if (!active || !options.isEnabled()) return;
|
|
107
|
+
if (ctx.signal?.aborted) {
|
|
108
|
+
// Esc cancels the user's run. Do not reset or resurrect it at settled.
|
|
109
|
+
clear();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
114
|
+
if (!active || !options.isEnabled() || !ctx.isIdle()) return;
|
|
115
|
+
if (state.phase === "compacting" && state.attempt.continuationStarted) {
|
|
116
|
+
const preceding = state.attempt;
|
|
117
|
+
if (!preceding.nextRequested) {
|
|
118
|
+
release(preceding);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// This settled event belongs to the continuation started by preceding.
|
|
122
|
+
// If it requested another reset, retain preceding until that reset's own
|
|
123
|
+
// continuation settles. Its eventual nested handler only releases its own
|
|
124
|
+
// waiter, so it never awaits itself.
|
|
125
|
+
const next = begin(ctx);
|
|
126
|
+
return next.wait.then(() => release(preceding));
|
|
127
|
+
}
|
|
128
|
+
if (state.phase !== "requested") return;
|
|
129
|
+
// One owner for requested resets. Consume the request before any external call;
|
|
130
|
+
// repeated settled events and reentrant callbacks are harmless.
|
|
131
|
+
return begin(ctx).wait;
|
|
77
132
|
});
|
|
78
133
|
|
|
79
134
|
pi.on("session_before_compact", (event, ctx) => {
|
|
@@ -103,9 +158,15 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
|
|
|
103
158
|
|
|
104
159
|
return {
|
|
105
160
|
request() {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
161
|
+
if (state.phase === "idle") {
|
|
162
|
+
state = { phase: "requested" };
|
|
163
|
+
return "rollover_requested";
|
|
164
|
+
}
|
|
165
|
+
if (state.phase === "compacting" && state.attempt.continuationStarted && !state.attempt.nextRequested) {
|
|
166
|
+
state.attempt.nextRequested = true;
|
|
167
|
+
return "rollover_requested";
|
|
168
|
+
}
|
|
169
|
+
return "rollover_already_pending";
|
|
109
170
|
},
|
|
110
171
|
clear,
|
|
111
172
|
};
|
package/src/tool-output.ts
CHANGED
|
@@ -115,16 +115,14 @@ export function readCharacterWindow<T>(text: string, offsetChars: number | undef
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* metadata (notes add their timestamps) inside the same brackets.
|
|
118
|
+
* Fixed metadata block preceding any raw character-window payload. Callers supply their
|
|
119
|
+
* source identity fields in wire order; range and continuation semantics are shared.
|
|
121
120
|
*/
|
|
122
|
-
export function
|
|
123
|
-
// The range end is offset + delivered count, never `total_chars`: a read resolved past the
|
|
124
|
-
// end delivers zero characters there, and the header must not render an inverted range.
|
|
121
|
+
export function readWindowBlock(identity: ReadonlyArray<readonly [string, string]>, window: CharacterWindow): string {
|
|
125
122
|
const end = window.offset_chars + Array.from(window.content).length;
|
|
126
|
-
const
|
|
127
|
-
|
|
123
|
+
const next = window.next_offset_chars === null ? "null" : String(window.next_offset_chars);
|
|
124
|
+
const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
|
|
125
|
+
return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
/**
|
|
@@ -183,9 +181,8 @@ export function output(value: unknown, details?: unknown, terminate = false) {
|
|
|
183
181
|
}
|
|
184
182
|
|
|
185
183
|
/**
|
|
186
|
-
* Encode a prose payload as raw text:
|
|
187
|
-
* verbatim.
|
|
188
|
-
* `details` carries the slim metadata object and never duplicates the payload.
|
|
184
|
+
* Encode a prose payload as raw text: metadata prefix, a blank line, then the payload
|
|
185
|
+
* verbatim. `details` carries the slim metadata object and never duplicates the payload.
|
|
189
186
|
*/
|
|
190
187
|
export function outputRaw(header: string, content: string, details: unknown, terminate = false) {
|
|
191
188
|
return { content: [{ type: "text" as const, text: `${header}\n${content}` }], details, terminate };
|