@wassname2/pi-annotated-journal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/index.ts +255 -0
- package/package.json +44 -0
- package/scripts/export-supervision.mjs +242 -0
- package/src/core.ts +250 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oliver Maclaren
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @wassname2/pi-annotated-journal
|
|
2
|
+
|
|
3
|
+
Annotate recent Pi messages in `$VISUAL` or `$EDITOR`, and keep the feedback in a Markdown journal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@wassname2/pi-annotated-journal
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
/annotate # last 6 user/assistant messages
|
|
11
|
+
/annotate 10 # last 10
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Pi opens a Markdown transcript with every source line blockquoted. Write feedback as unquoted
|
|
15
|
+
text near the line it refers to, then save and close. The extension then:
|
|
16
|
+
|
|
17
|
+
1. appends the annotated transcript to `docs/human_journal.md`;
|
|
18
|
+
2. records the entry in the Pi session;
|
|
19
|
+
3. sends the transcript to the model as hidden context and starts the next turn.
|
|
20
|
+
|
|
21
|
+
Nothing is saved or sent if you add no unquoted text. An editor error cancels the operation.
|
|
22
|
+
Without `$VISUAL` or `$EDITOR`, Pi's built-in editor is used.
|
|
23
|
+
|
|
24
|
+
Every interactive or RPC prompt is also appended as a quoted `User message` record. Extension-injected messages are excluded. The journal is not added to model context automatically; ask Pi to read it when it is useful.
|
|
25
|
+
|
|
26
|
+
`PI_ANNOTATE_JOURNAL` sets the journal path. Relative paths resolve from Pi's working directory.
|
|
27
|
+
|
|
28
|
+
<!-- PI -->
|
|
29
|
+
|
|
30
|
+
## Export supervision data
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pi-supervision-export --journal docs/human_journal.md --output supervision.jsonl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Reads the session files referenced by the journal. `--session PATH`, repeatable, restricts the
|
|
37
|
+
export. Records are typed `user_message` or `human_annotation`; an annotation carries the message
|
|
38
|
+
ID and the source line it follows.
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
npm run check
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The external-editor flow is adapted from
|
|
48
|
+
[pi-annotated-reply](https://github.com/omaclaren/pi-annotated-reply).
|
package/index.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { BorderedLoader } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
buildAnnotationTemplate,
|
|
10
|
+
buildJournalRecord,
|
|
11
|
+
buildPrompt,
|
|
12
|
+
buildUserMessageRecord,
|
|
13
|
+
extractAnnotations,
|
|
14
|
+
journalHeader,
|
|
15
|
+
type ConversationMessage,
|
|
16
|
+
type ConversationRole,
|
|
17
|
+
type JournalRecordMetadata,
|
|
18
|
+
type UserMessageRecordMetadata,
|
|
19
|
+
} from "./src/core.ts";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MESSAGE_COUNT = 6;
|
|
22
|
+
const MAX_MESSAGE_COUNT = 100;
|
|
23
|
+
const DEFAULT_JOURNAL_PATH = "docs/human_journal.md";
|
|
24
|
+
|
|
25
|
+
type EditorResult =
|
|
26
|
+
| { ok: true; edited: string }
|
|
27
|
+
| { ok: false; cancelled: true }
|
|
28
|
+
| { ok: false; message: string };
|
|
29
|
+
|
|
30
|
+
type ParsedEditor = { command: string; args: string[] };
|
|
31
|
+
|
|
32
|
+
function parseCount(args: string): number | null {
|
|
33
|
+
const value = args.trim();
|
|
34
|
+
if (!value) return DEFAULT_MESSAGE_COUNT;
|
|
35
|
+
if (!/^\d+$/.test(value)) return null;
|
|
36
|
+
const count = Number(value);
|
|
37
|
+
return Number.isSafeInteger(count) && count >= 1 && count <= MAX_MESSAGE_COUNT ? count : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function textFromContent(content: unknown): string {
|
|
41
|
+
if (typeof content === "string") return content.trimEnd();
|
|
42
|
+
if (!Array.isArray(content)) return "";
|
|
43
|
+
return content
|
|
44
|
+
.map((part) => {
|
|
45
|
+
if (!part || typeof part !== "object" || !("type" in part)) return "";
|
|
46
|
+
if (part.type === "text" && "text" in part && typeof part.text === "string") return part.text;
|
|
47
|
+
if (part.type === "image") return "[image omitted from annotation view]";
|
|
48
|
+
return "";
|
|
49
|
+
})
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join("\n\n")
|
|
52
|
+
.trimEnd();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function conversationMessage(entry: SessionEntry): ConversationMessage | null {
|
|
56
|
+
if (entry.type !== "message") return null;
|
|
57
|
+
const message = entry.message;
|
|
58
|
+
if (message.role !== "user" && message.role !== "assistant") return null;
|
|
59
|
+
const text = textFromContent(message.content);
|
|
60
|
+
if (!text.trim()) return null;
|
|
61
|
+
return {
|
|
62
|
+
id: entry.id,
|
|
63
|
+
role: message.role as ConversationRole,
|
|
64
|
+
timestamp: entry.timestamp,
|
|
65
|
+
text,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recentConversation(ctx: ExtensionCommandContext, count: number): ConversationMessage[] {
|
|
70
|
+
return ctx.sessionManager
|
|
71
|
+
.getBranch()
|
|
72
|
+
.map(conversationMessage)
|
|
73
|
+
.filter((message): message is ConversationMessage => message !== null)
|
|
74
|
+
.slice(-count);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseEditorCommand(spec: string): ParsedEditor | null {
|
|
78
|
+
const tokens = spec.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
79
|
+
if (!tokens?.length) return null;
|
|
80
|
+
const unquote = (token: string) => {
|
|
81
|
+
if (token.length >= 2 && ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")))) {
|
|
82
|
+
return token.slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
return token;
|
|
85
|
+
};
|
|
86
|
+
const command = unquote(tokens[0] ?? "").trim();
|
|
87
|
+
if (!command) return null;
|
|
88
|
+
return { command, args: tokens.slice(1).map(unquote) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function editExternally(ctx: ExtensionCommandContext, prefill: string, commandSpec: string): Promise<EditorResult> {
|
|
92
|
+
const editor = parseEditorCommand(commandSpec);
|
|
93
|
+
if (!editor) return { ok: false, message: `Could not parse $VISUAL/$EDITOR: ${commandSpec}` };
|
|
94
|
+
|
|
95
|
+
const result = await ctx.ui.custom<EditorResult>((tui, theme, _keybindings, done) => {
|
|
96
|
+
const loader = new BorderedLoader(tui, theme, `Opening ${editor.command}...`);
|
|
97
|
+
let settled = false;
|
|
98
|
+
const finish = (value: EditorResult) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
done(value);
|
|
102
|
+
};
|
|
103
|
+
loader.onAbort = () => finish({ ok: false, cancelled: true });
|
|
104
|
+
|
|
105
|
+
void Promise.resolve().then(() => {
|
|
106
|
+
const tempDirectory = mkdtempSync(join(tmpdir(), "pi-annotate-"));
|
|
107
|
+
const tempFile = join(tempDirectory, "annotation.md");
|
|
108
|
+
let tuiStopped = false;
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(tempFile, prefill, { encoding: "utf8", mode: 0o600 });
|
|
111
|
+
if (settled) return;
|
|
112
|
+
tui.stop();
|
|
113
|
+
tuiStopped = true;
|
|
114
|
+
const run = spawnSync(editor.command, [...editor.args, tempFile], { stdio: "inherit" });
|
|
115
|
+
if (run.error) return finish({ ok: false, message: run.error.message });
|
|
116
|
+
if (run.status !== 0) return finish({ ok: false, cancelled: true });
|
|
117
|
+
finish({ ok: true, edited: readFileSync(tempFile, "utf8") });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(tempDirectory, { recursive: true, force: true });
|
|
122
|
+
if (tuiStopped) {
|
|
123
|
+
tui.start();
|
|
124
|
+
tui.requestRender(true);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return loader;
|
|
130
|
+
});
|
|
131
|
+
return result ?? { ok: false, cancelled: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function editAnnotation(ctx: ExtensionCommandContext, prefill: string): Promise<EditorResult> {
|
|
135
|
+
const commandSpec = process.env.VISUAL?.trim() || process.env.EDITOR?.trim();
|
|
136
|
+
if (ctx.mode === "tui" && commandSpec) return editExternally(ctx, prefill, commandSpec);
|
|
137
|
+
if (!ctx.hasUI) return { ok: false, message: "/annotate requires interactive Pi mode." };
|
|
138
|
+
if (!commandSpec) ctx.ui.notify("No $VISUAL/$EDITOR set; using Pi's built-in editor.", "warning");
|
|
139
|
+
const edited = await ctx.ui.editor("Annotate recent conversation", prefill);
|
|
140
|
+
return edited === undefined ? { ok: false, cancelled: true } : { ok: true, edited };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function journalPath(ctx: { cwd: string }): string {
|
|
144
|
+
const configured = process.env.PI_ANNOTATE_JOURNAL?.trim() || DEFAULT_JOURNAL_PATH;
|
|
145
|
+
return isAbsolute(configured) ? configured : resolve(ctx.cwd, configured);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendJournal(path: string, record: string): void {
|
|
149
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
150
|
+
if (!existsSync(path)) {
|
|
151
|
+
writeFileSync(path, journalHeader(), { encoding: "utf8", mode: 0o600 });
|
|
152
|
+
}
|
|
153
|
+
const existing = readFileSync(path, "utf8");
|
|
154
|
+
const separator = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
155
|
+
appendFileSync(path, `${separator}${record}`, "utf8");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default function (pi: ExtensionAPI) {
|
|
159
|
+
pi.on("input", (event, ctx) => {
|
|
160
|
+
if (event.source === "extension") return { action: "continue" };
|
|
161
|
+
const metadata: UserMessageRecordMetadata = {
|
|
162
|
+
schema: 1,
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
165
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? null,
|
|
166
|
+
cwd: ctx.cwd,
|
|
167
|
+
source: event.source,
|
|
168
|
+
};
|
|
169
|
+
appendJournal(journalPath(ctx), buildUserMessageRecord(metadata, event.text));
|
|
170
|
+
return { action: "continue" };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.registerCommand("annotate", {
|
|
174
|
+
description: "Annotate the last N user/assistant messages in $EDITOR (default: 6), journal the feedback, and send it",
|
|
175
|
+
handler: async (args, ctx) => {
|
|
176
|
+
const count = parseCount(args);
|
|
177
|
+
if (count === null) {
|
|
178
|
+
ctx.ui.notify(`Usage: /annotate [N], where N is 1-${MAX_MESSAGE_COUNT}.`, "warning");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await ctx.waitForIdle();
|
|
183
|
+
const messages = recentConversation(ctx, count);
|
|
184
|
+
if (messages.length === 0) {
|
|
185
|
+
ctx.ui.notify("No user or assistant text found in the current branch.", "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const edited = await editAnnotation(ctx, buildAnnotationTemplate(messages));
|
|
190
|
+
if (!edited.ok) {
|
|
191
|
+
if ("cancelled" in edited) ctx.ui.notify("Annotation cancelled.", "info");
|
|
192
|
+
else ctx.ui.notify(edited.message, "error");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let annotations;
|
|
197
|
+
try {
|
|
198
|
+
annotations = extractAnnotations(edited.edited, messages.map((message) => message.id));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (annotations.length === 0) {
|
|
204
|
+
ctx.ui.notify("No unquoted annotations found; nothing was sent or journaled.", "info");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const createdAt = new Date().toISOString();
|
|
209
|
+
const path = journalPath(ctx);
|
|
210
|
+
const metadata: JournalRecordMetadata = {
|
|
211
|
+
schema: 1,
|
|
212
|
+
recordId: randomUUID(),
|
|
213
|
+
createdAt,
|
|
214
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
215
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? null,
|
|
216
|
+
cwd: ctx.cwd,
|
|
217
|
+
messageIds: messages.map((message) => message.id),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
appendJournal(path, buildJournalRecord(metadata, edited.edited));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
ctx.ui.notify(`Could not write ${path}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pi.appendEntry("pi-annotate-journal", {
|
|
228
|
+
recordId: metadata.recordId,
|
|
229
|
+
journalPath: path,
|
|
230
|
+
messageIds: metadata.messageIds,
|
|
231
|
+
annotationCount: annotations.length,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const prompt = buildPrompt(edited.edited);
|
|
235
|
+
try {
|
|
236
|
+
pi.sendMessage(
|
|
237
|
+
{
|
|
238
|
+
customType: "pi-annotated-journal",
|
|
239
|
+
content: prompt,
|
|
240
|
+
display: false,
|
|
241
|
+
details: { recordId: metadata.recordId },
|
|
242
|
+
},
|
|
243
|
+
{ triggerTurn: true },
|
|
244
|
+
);
|
|
245
|
+
ctx.ui.notify(`Saved ${annotations.length} annotation${annotations.length === 1 ? "" : "s"} to ${path}.`, "info");
|
|
246
|
+
} catch (error) {
|
|
247
|
+
ctx.ui.setEditorText(prompt);
|
|
248
|
+
ctx.ui.notify(
|
|
249
|
+
`Journal saved, but automatic submission failed. The prompt is loaded in Pi's editor: ${error instanceof Error ? error.message : String(error)}`,
|
|
250
|
+
"error",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wassname2/pi-annotated-journal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Annotate recent Pi conversation turns and keep the feedback in a Markdown journal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"annotation",
|
|
12
|
+
"human-in-the-loop",
|
|
13
|
+
"supervision"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"pi-supervision-export": "./scripts/export-supervision.mjs"
|
|
17
|
+
},
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
28
|
+
"@types/node": "^24.3.0",
|
|
29
|
+
"typescript": "^5.7.3"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"check": "npm run typecheck && npm test"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/wassname/pi-annotated-journal.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/wassname/pi-annotated-journal#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/wassname/pi-annotated-journal/issues"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const BODY_START = "<!-- pi-annotate-body -->";
|
|
7
|
+
const BODY_END = "<!-- /pi-annotate-body -->";
|
|
8
|
+
const RECORD_END = "<!-- /pi-annotate-record -->";
|
|
9
|
+
const MESSAGE_PREFIX = "<!-- pi-annotate-message:";
|
|
10
|
+
|
|
11
|
+
function usage(exitCode = 0) {
|
|
12
|
+
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
13
|
+
stream.write(`Usage: pi-supervision-export [options]\n\nOptions:\n --journal PATH Journal path (default: docs/human_journal.md)\n --session PATH Pi session JSONL; repeat for more than one\n --output PATH Write JSONL here instead of stdout\n --help Show this help\n\nWithout --session, session files referenced by journal records are read automatically.\n`);
|
|
14
|
+
process.exit(exitCode);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const options = { journal: "docs/human_journal.md", sessions: [], output: null };
|
|
19
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === "--help" || arg === "-h") usage();
|
|
22
|
+
if (arg === "--journal" || arg === "--session" || arg === "--output") {
|
|
23
|
+
const value = argv[++i];
|
|
24
|
+
if (!value) usage(2);
|
|
25
|
+
if (arg === "--journal") options.journal = value;
|
|
26
|
+
if (arg === "--session") options.sessions.push(value);
|
|
27
|
+
if (arg === "--output") options.output = value;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
process.stderr.write(`Unknown argument: ${arg}\n`);
|
|
31
|
+
usage(2);
|
|
32
|
+
}
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decode(encoded) {
|
|
37
|
+
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function annotationBody(record) {
|
|
41
|
+
const lines = record.replace(/\r\n/g, "\n").split("\n");
|
|
42
|
+
const start = lines.indexOf(BODY_START);
|
|
43
|
+
const end = lines.indexOf(BODY_END, start + 1);
|
|
44
|
+
if (start < 0 || end < 0 || end <= start) throw new Error("Malformed annotation body");
|
|
45
|
+
return lines.slice(start + 1, end).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseLegacyAnnotations(body) {
|
|
49
|
+
const annotations = [];
|
|
50
|
+
let messageId = null;
|
|
51
|
+
let role = null;
|
|
52
|
+
let sourceLine = 0;
|
|
53
|
+
let pending = [];
|
|
54
|
+
let pendingAfterLine = 0;
|
|
55
|
+
const flush = () => {
|
|
56
|
+
const text = pending.join("\n").trim();
|
|
57
|
+
if (text) annotations.push({ messageId, role, afterSourceLine: pendingAfterLine, text });
|
|
58
|
+
pending = [];
|
|
59
|
+
};
|
|
60
|
+
for (const line of body.split("\n")) {
|
|
61
|
+
if (line.startsWith(MESSAGE_PREFIX) && line.endsWith(" -->")) {
|
|
62
|
+
flush();
|
|
63
|
+
const marker = decode(line.slice(MESSAGE_PREFIX.length, -4));
|
|
64
|
+
messageId = marker.id;
|
|
65
|
+
role = marker.role;
|
|
66
|
+
sourceLine = 0;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (line === "## User" || line === "## Assistant" || /^<!--.*-->$/.test(line.trim())) continue;
|
|
70
|
+
if (line.startsWith(">")) {
|
|
71
|
+
flush();
|
|
72
|
+
sourceLine += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line.trim() === "") {
|
|
76
|
+
flush();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (pending.length === 0) pendingAfterLine = sourceLine;
|
|
80
|
+
pending.push(line);
|
|
81
|
+
}
|
|
82
|
+
flush();
|
|
83
|
+
return annotations;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseCurrentAnnotations(body, messageIds) {
|
|
87
|
+
const annotations = [];
|
|
88
|
+
let sectionIndex = 0;
|
|
89
|
+
let role = null;
|
|
90
|
+
let sourceLine = 0;
|
|
91
|
+
let pending = [];
|
|
92
|
+
let pendingAfterLine = 0;
|
|
93
|
+
const flush = () => {
|
|
94
|
+
const text = pending.join("\n").trim();
|
|
95
|
+
if (text) annotations.push({
|
|
96
|
+
messageId: messageIds[sectionIndex] ?? null,
|
|
97
|
+
role,
|
|
98
|
+
afterSourceLine: pendingAfterLine,
|
|
99
|
+
text,
|
|
100
|
+
});
|
|
101
|
+
pending = [];
|
|
102
|
+
};
|
|
103
|
+
for (const line of body.split("\n")) {
|
|
104
|
+
if (line === "# Annotate") continue;
|
|
105
|
+
if (line === "---") {
|
|
106
|
+
flush();
|
|
107
|
+
sectionIndex += 1;
|
|
108
|
+
role = null;
|
|
109
|
+
sourceLine = 0;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (line.startsWith(">")) {
|
|
113
|
+
flush();
|
|
114
|
+
if (sourceLine === 0) {
|
|
115
|
+
const match = line.match(/^> (User|Assistant):(?: |$)/);
|
|
116
|
+
role = match?.[1] === "User" ? "user" : match?.[1] === "Assistant" ? "assistant" : null;
|
|
117
|
+
}
|
|
118
|
+
sourceLine += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (line.trim() === "") {
|
|
122
|
+
flush();
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (pending.length === 0) pendingAfterLine = sourceLine;
|
|
126
|
+
pending.push(line);
|
|
127
|
+
}
|
|
128
|
+
flush();
|
|
129
|
+
return annotations;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseAnnotations(record, messageIds) {
|
|
133
|
+
const body = annotationBody(record);
|
|
134
|
+
return body.includes("<!-- pi-annotate-template:v1 -->")
|
|
135
|
+
? parseLegacyAnnotations(body)
|
|
136
|
+
: parseCurrentAnnotations(body, messageIds);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseJournal(path) {
|
|
140
|
+
const lines = readFileSync(path, "utf8").replace(/\r\n/g, "\n").split("\n");
|
|
141
|
+
const records = [];
|
|
142
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
143
|
+
const match = lines[index]?.match(/^<!-- pi-annotate-record:([A-Za-z0-9_-]+) -->$/);
|
|
144
|
+
if (!match) continue;
|
|
145
|
+
const end = lines.indexOf(RECORD_END, index + 1);
|
|
146
|
+
if (end < 0) throw new Error(`Unterminated journal record at line ${index + 1}`);
|
|
147
|
+
const metadata = decode(match[1]);
|
|
148
|
+
const template = lines.slice(index + 1, end).join("\n").trim();
|
|
149
|
+
records.push({ metadata, annotations: parseAnnotations(template, metadata.messageIds ?? []) });
|
|
150
|
+
index = end;
|
|
151
|
+
}
|
|
152
|
+
return records;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function contentText(content) {
|
|
156
|
+
if (typeof content === "string") return content;
|
|
157
|
+
if (!Array.isArray(content)) return "";
|
|
158
|
+
return content
|
|
159
|
+
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
|
160
|
+
.map((part) => part.text)
|
|
161
|
+
.join("\n\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readSession(path) {
|
|
165
|
+
const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
|
|
166
|
+
let sessionId = null;
|
|
167
|
+
const records = [];
|
|
168
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
169
|
+
let entry;
|
|
170
|
+
try {
|
|
171
|
+
entry = JSON.parse(lines[index]);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new Error(`${path}:${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
174
|
+
}
|
|
175
|
+
if (entry.type === "session") sessionId = entry.id;
|
|
176
|
+
if (entry.type !== "message" || entry.message?.role !== "user") continue;
|
|
177
|
+
const text = contentText(entry.message.content);
|
|
178
|
+
if (text.includes("<!-- pi-annotate-template:v1 -->")) continue;
|
|
179
|
+
records.push({
|
|
180
|
+
type: "user_message",
|
|
181
|
+
timestamp: entry.timestamp ?? new Date(entry.message.timestamp).toISOString(),
|
|
182
|
+
sessionId,
|
|
183
|
+
sessionFile: path,
|
|
184
|
+
entryId: entry.id,
|
|
185
|
+
parentId: entry.parentId ?? null,
|
|
186
|
+
text,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return { sessionId, records };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function main() {
|
|
193
|
+
const options = parseArgs(process.argv.slice(2));
|
|
194
|
+
const journalPath = resolve(options.journal);
|
|
195
|
+
const journalRecords = parseJournal(journalPath);
|
|
196
|
+
const explicitSessions = options.sessions.map((path) => resolve(path));
|
|
197
|
+
const sessionPaths = explicitSessions.length > 0
|
|
198
|
+
? explicitSessions
|
|
199
|
+
: [...new Set(journalRecords.map((record) => record.metadata.sessionFile).filter(Boolean))];
|
|
200
|
+
|
|
201
|
+
const output = [];
|
|
202
|
+
const selectedSessionIds = new Set();
|
|
203
|
+
for (const path of sessionPaths) {
|
|
204
|
+
try {
|
|
205
|
+
const session = readSession(path);
|
|
206
|
+
if (session.sessionId) selectedSessionIds.add(session.sessionId);
|
|
207
|
+
output.push(...session.records);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
process.stderr.write(`warning: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const record of journalRecords) {
|
|
214
|
+
if (explicitSessions.length > 0 && !selectedSessionIds.has(record.metadata.sessionId)) continue;
|
|
215
|
+
record.annotations.forEach((annotation, ordinal) => {
|
|
216
|
+
output.push({
|
|
217
|
+
type: "human_annotation",
|
|
218
|
+
timestamp: record.metadata.createdAt,
|
|
219
|
+
sessionId: record.metadata.sessionId,
|
|
220
|
+
sessionFile: record.metadata.sessionFile,
|
|
221
|
+
recordId: record.metadata.recordId,
|
|
222
|
+
ordinal,
|
|
223
|
+
messageId: annotation.messageId,
|
|
224
|
+
role: annotation.role,
|
|
225
|
+
afterSourceLine: annotation.afterSourceLine,
|
|
226
|
+
text: annotation.text,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
output.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)) || (a.ordinal ?? -1) - (b.ordinal ?? -1));
|
|
232
|
+
const jsonl = output.map((record) => JSON.stringify(record)).join("\n") + (output.length > 0 ? "\n" : "");
|
|
233
|
+
if (options.output) writeFileSync(resolve(options.output), jsonl, "utf8");
|
|
234
|
+
else process.stdout.write(jsonl);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
main();
|
|
239
|
+
} catch (error) {
|
|
240
|
+
process.stderr.write(`pi-supervision-export: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
export const TEMPLATE_VERSION = 2;
|
|
2
|
+
|
|
3
|
+
export type ConversationRole = "user" | "assistant";
|
|
4
|
+
|
|
5
|
+
export type ConversationMessage = {
|
|
6
|
+
id: string;
|
|
7
|
+
role: ConversationRole;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Annotation = {
|
|
13
|
+
messageId: string | null;
|
|
14
|
+
role: ConversationRole | null;
|
|
15
|
+
afterSourceLine: number;
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type JournalRecordMetadata = {
|
|
20
|
+
schema: 1;
|
|
21
|
+
recordId: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
sessionFile: string | null;
|
|
25
|
+
cwd: string;
|
|
26
|
+
messageIds: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type UserMessageRecordMetadata = {
|
|
30
|
+
schema: 1;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
sessionFile: string | null;
|
|
34
|
+
cwd: string;
|
|
35
|
+
source: "interactive" | "rpc";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const TEMPLATE_HEADING = "# Annotate";
|
|
39
|
+
const SEPARATOR = "---";
|
|
40
|
+
const BODY_START = "<!-- pi-annotate-body -->";
|
|
41
|
+
const BODY_END = "<!-- /pi-annotate-body -->";
|
|
42
|
+
const RECORD_PREFIX = "<!-- pi-annotate-record:";
|
|
43
|
+
const RECORD_END = "<!-- /pi-annotate-record -->";
|
|
44
|
+
const USER_MESSAGE_PREFIX = "<!-- pi-user-message:";
|
|
45
|
+
const USER_MESSAGE_END = "<!-- /pi-user-message -->";
|
|
46
|
+
const LEGACY_MESSAGE_PREFIX = "<!-- pi-annotate-message:";
|
|
47
|
+
|
|
48
|
+
function encodeMetadata(value: unknown): string {
|
|
49
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeMetadata<T>(encoded: string): T {
|
|
53
|
+
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function quoteMarkdown(text: string): string {
|
|
57
|
+
return text
|
|
58
|
+
.replace(/\r\n/g, "\n")
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"))
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteMessage(message: ConversationMessage): string {
|
|
65
|
+
const role = message.role === "user" ? "User" : "Assistant";
|
|
66
|
+
const [first = "", ...rest] = message.text.replace(/\r\n/g, "\n").split("\n");
|
|
67
|
+
const lines = [`> ${role}: ${first}`, ...rest.map((line) => (line.length > 0 ? `> ${line}` : ">"))];
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAnnotationTemplate(messages: ConversationMessage[]): string {
|
|
72
|
+
return [TEMPLATE_HEADING, "", messages.map(quoteMessage).join(`\n\n${SEPARATOR}\n\n`), ""].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function flushAnnotation(
|
|
76
|
+
annotations: Annotation[],
|
|
77
|
+
pending: string[],
|
|
78
|
+
messageId: string | null,
|
|
79
|
+
role: ConversationRole | null,
|
|
80
|
+
afterSourceLine: number,
|
|
81
|
+
): void {
|
|
82
|
+
const text = pending.join("\n").trim();
|
|
83
|
+
if (text) annotations.push({ messageId, role, afterSourceLine, text });
|
|
84
|
+
pending.length = 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractCurrentAnnotations(markdown: string, messageIds: readonly string[]): Annotation[] {
|
|
88
|
+
const annotations: Annotation[] = [];
|
|
89
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
90
|
+
let sectionIndex = 0;
|
|
91
|
+
let sectionCount = 0;
|
|
92
|
+
let role: ConversationRole | null = null;
|
|
93
|
+
let sourceLine = 0;
|
|
94
|
+
let pending: string[] = [];
|
|
95
|
+
let pendingAfterLine = 0;
|
|
96
|
+
|
|
97
|
+
const flush = () => flushAnnotation(
|
|
98
|
+
annotations,
|
|
99
|
+
pending,
|
|
100
|
+
messageIds[sectionIndex] ?? null,
|
|
101
|
+
role,
|
|
102
|
+
pendingAfterLine,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (line === TEMPLATE_HEADING && sectionCount === 0 && sourceLine === 0) continue;
|
|
107
|
+
if (line === SEPARATOR) {
|
|
108
|
+
flush();
|
|
109
|
+
sectionIndex += 1;
|
|
110
|
+
role = null;
|
|
111
|
+
sourceLine = 0;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (line.startsWith(">")) {
|
|
115
|
+
flush();
|
|
116
|
+
if (sourceLine === 0) {
|
|
117
|
+
const match = line.match(/^> (User|Assistant):(?: |$)/);
|
|
118
|
+
if (!match) throw new Error("A message heading was changed. Re-run /annotate and keep the quoted User/Assistant prefixes.");
|
|
119
|
+
role = match[1] === "User" ? "user" : "assistant";
|
|
120
|
+
sectionCount += 1;
|
|
121
|
+
}
|
|
122
|
+
sourceLine += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (line.trim() === "") {
|
|
126
|
+
flush();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (pending.length === 0) pendingAfterLine = sourceLine;
|
|
130
|
+
pending.push(line);
|
|
131
|
+
}
|
|
132
|
+
flush();
|
|
133
|
+
|
|
134
|
+
if (messageIds.length > 0 && sectionCount !== messageIds.length) {
|
|
135
|
+
throw new Error("Message separators were changed. Re-run /annotate and keep the quoted messages and --- separators.");
|
|
136
|
+
}
|
|
137
|
+
return annotations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function bodyFromJournalRecord(markdown: string): string {
|
|
141
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
142
|
+
const start = lines.indexOf(BODY_START);
|
|
143
|
+
const end = lines.indexOf(BODY_END, start + 1);
|
|
144
|
+
if (start < 0 || end < 0 || end <= start) throw new Error("Journal record has invalid body markers.");
|
|
145
|
+
return lines.slice(start + 1, end).join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function extractLegacyAnnotations(markdown: string): Annotation[] {
|
|
149
|
+
const body = bodyFromJournalRecord(markdown);
|
|
150
|
+
const annotations: Annotation[] = [];
|
|
151
|
+
let messageId: string | null = null;
|
|
152
|
+
let role: ConversationRole | null = null;
|
|
153
|
+
let sourceLine = 0;
|
|
154
|
+
let pending: string[] = [];
|
|
155
|
+
let pendingAfterLine = 0;
|
|
156
|
+
const flush = () => flushAnnotation(annotations, pending, messageId, role, pendingAfterLine);
|
|
157
|
+
|
|
158
|
+
for (const line of body.split("\n")) {
|
|
159
|
+
if (line.startsWith(LEGACY_MESSAGE_PREFIX) && line.endsWith(" -->")) {
|
|
160
|
+
flush();
|
|
161
|
+
const metadata = decodeMetadata<{ id: string; role: ConversationRole }>(line.slice(LEGACY_MESSAGE_PREFIX.length, -4));
|
|
162
|
+
messageId = metadata.id;
|
|
163
|
+
role = metadata.role;
|
|
164
|
+
sourceLine = 0;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (line === "## User" || line === "## Assistant" || /^<!--.*-->$/.test(line.trim())) continue;
|
|
168
|
+
if (line.startsWith(">")) {
|
|
169
|
+
flush();
|
|
170
|
+
sourceLine += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (line.trim() === "") {
|
|
174
|
+
flush();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (pending.length === 0) pendingAfterLine = sourceLine;
|
|
178
|
+
pending.push(line);
|
|
179
|
+
}
|
|
180
|
+
flush();
|
|
181
|
+
return annotations;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function extractAnnotations(markdown: string, messageIds: readonly string[] = []): Annotation[] {
|
|
185
|
+
return extractCurrentAnnotations(markdown, messageIds);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildPrompt(editedTemplate: string): string {
|
|
189
|
+
return editedTemplate.replace(/\r\n/g, "\n").trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildJournalRecord(metadata: JournalRecordMetadata, editedTemplate: string): string {
|
|
193
|
+
const encoded = encodeMetadata(metadata);
|
|
194
|
+
return [
|
|
195
|
+
`## ${metadata.createdAt} · session ${metadata.sessionId}`,
|
|
196
|
+
"",
|
|
197
|
+
`${RECORD_PREFIX}${encoded} -->`,
|
|
198
|
+
BODY_START,
|
|
199
|
+
editedTemplate.replace(/\r\n/g, "\n").trim(),
|
|
200
|
+
BODY_END,
|
|
201
|
+
RECORD_END,
|
|
202
|
+
"",
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildUserMessageRecord(metadata: UserMessageRecordMetadata, text: string): string {
|
|
207
|
+
return [
|
|
208
|
+
`## ${metadata.createdAt} · User message`,
|
|
209
|
+
"",
|
|
210
|
+
`${USER_MESSAGE_PREFIX}${encodeMetadata(metadata)} -->`,
|
|
211
|
+
quoteMarkdown(text),
|
|
212
|
+
USER_MESSAGE_END,
|
|
213
|
+
"",
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function journalHeader(): string {
|
|
218
|
+
return [
|
|
219
|
+
"# Human supervision journal",
|
|
220
|
+
"",
|
|
221
|
+
"Quoted `User message` records are exact submitted prompts. `/annotate` records contain quoted prior conversation and unquoted feedback.",
|
|
222
|
+
"",
|
|
223
|
+
].join("\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type ParsedJournalRecord = {
|
|
227
|
+
metadata: JournalRecordMetadata;
|
|
228
|
+
template: string;
|
|
229
|
+
annotations: Annotation[];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export function parseJournalRecords(markdown: string): ParsedJournalRecord[] {
|
|
233
|
+
const records: ParsedJournalRecord[] = [];
|
|
234
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
235
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
236
|
+
const match = lines[index]?.match(/^<!-- pi-annotate-record:([A-Za-z0-9_-]+) -->$/);
|
|
237
|
+
if (!match) continue;
|
|
238
|
+
const end = lines.indexOf(RECORD_END, index + 1);
|
|
239
|
+
if (end < 0) throw new Error("Journal contains an unterminated pi-annotate record.");
|
|
240
|
+
const metadata = decodeMetadata<JournalRecordMetadata>(match[1] ?? "");
|
|
241
|
+
const recordBody = lines.slice(index + 1, end).join("\n").trim();
|
|
242
|
+
const template = bodyFromJournalRecord(recordBody);
|
|
243
|
+
const annotations = template.includes("<!-- pi-annotate-template:v1 -->")
|
|
244
|
+
? extractLegacyAnnotations(recordBody)
|
|
245
|
+
: extractCurrentAnnotations(template, metadata.messageIds);
|
|
246
|
+
records.push({ metadata, template, annotations });
|
|
247
|
+
index = end;
|
|
248
|
+
}
|
|
249
|
+
return records;
|
|
250
|
+
}
|