@roodriigoooo/pi-docket 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/assets/docket_logo.jpeg +0 -0
  5. package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
  6. package/docs/adr/0002-rename-to-docket.md +44 -0
  7. package/docs/architecture.md +101 -0
  8. package/docs/bundle-guidelines.md +39 -0
  9. package/docs/configuration.md +191 -0
  10. package/docs/releases/0.4.0.md +93 -0
  11. package/extensions/artifact-catalog.ts +467 -0
  12. package/extensions/background-work.ts +510 -0
  13. package/extensions/checkpoint-commands.ts +147 -0
  14. package/extensions/checkpoint-lifecycle.ts +195 -0
  15. package/extensions/checkpoint-selector.ts +162 -0
  16. package/extensions/checkpoint-store.ts +230 -0
  17. package/extensions/checkpoint-summarizer.ts +141 -0
  18. package/extensions/docket-command-grammar.ts +319 -0
  19. package/extensions/docket-command-router.ts +626 -0
  20. package/extensions/docket-config.ts +88 -0
  21. package/extensions/docket-extension-surface.ts +43 -0
  22. package/extensions/docket-navigator.ts +585 -0
  23. package/extensions/docket.README.md +46 -0
  24. package/extensions/docket.ts +2965 -0
  25. package/extensions/event-log.ts +121 -0
  26. package/extensions/git-context.ts +44 -0
  27. package/extensions/loaded-artifact-context.ts +228 -0
  28. package/extensions/search-index.ts +140 -0
  29. package/extensions/types.ts +40 -0
  30. package/extensions/worker-activity.ts +402 -0
  31. package/extensions/worker-changes.ts +180 -0
  32. package/extensions/worker-commands.ts +251 -0
  33. package/extensions/worker-dock-cache.ts +147 -0
  34. package/extensions/worker-events.ts +87 -0
  35. package/extensions/worker-eviction.ts +55 -0
  36. package/extensions/worker-guardrails.md +125 -0
  37. package/extensions/worker-kinds/patcher.md +23 -0
  38. package/extensions/worker-kinds/scout.md +17 -0
  39. package/extensions/worker-kinds.ts +280 -0
  40. package/extensions/worker-result.ts +193 -0
  41. package/extensions/worker-store.ts +621 -0
  42. package/extensions/worker-summary-embed.ts +98 -0
  43. package/package.json +53 -0
@@ -0,0 +1,467 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { searchArtifacts } from "./search-index.js";
4
+ import type { Artifact, ArtifactKind, ArtifactSummary, CheckpointIndexEntry } from "./types.js";
5
+
6
+ export type ArtifactCatalogConfig = {
7
+ maxArtifacts: number;
8
+ maxBodyChars: number;
9
+ };
10
+
11
+ export type DocketRuntimeContext = {
12
+ cwd: string;
13
+ sessionManager: { getBranch(): unknown[] };
14
+ };
15
+
16
+ export type ArtifactCatalog = {
17
+ list(): Artifact[];
18
+ find(idOrRef: string): Artifact | undefined;
19
+ reference(artifact: Artifact): string;
20
+ fullText(artifact: Artifact): string;
21
+ inspect(artifact: Artifact): Promise<{ title: string; text: string }>;
22
+ search(query: string): Promise<Artifact[]>;
23
+ selectForCheckpoint(limit: number): Artifact[];
24
+ checkpointPayload(artifacts: Artifact[]): Array<Record<string, unknown>>;
25
+ summary(artifact: Artifact): ArtifactSummary;
26
+ };
27
+
28
+ type ToolCallInfo = {
29
+ id: string;
30
+ name: string;
31
+ args: Record<string, unknown>;
32
+ entryId: string;
33
+ timestamp?: number;
34
+ };
35
+
36
+ const CHECKPOINT_CUSTOM_TYPE = "docket:checkpoint";
37
+
38
+ export function textFromContent(content: unknown): string {
39
+ if (typeof content === "string") return content;
40
+ if (!Array.isArray(content)) return "";
41
+ return content
42
+ .map((part) => {
43
+ if (!part || typeof part !== "object") return "";
44
+ const block = part as { type?: string; text?: string; thinking?: string };
45
+ if (block.type === "text" && typeof block.text === "string") return block.text;
46
+ return "";
47
+ })
48
+ .filter(Boolean)
49
+ .join("\n");
50
+ }
51
+
52
+ function toolCallsFromContent(content: unknown): Array<{ id: string; name: string; args: Record<string, unknown> }> {
53
+ if (!Array.isArray(content)) return [];
54
+ const out: Array<{ id: string; name: string; args: Record<string, unknown> }> = [];
55
+ for (const part of content) {
56
+ if (!part || typeof part !== "object") continue;
57
+ const block = part as { type?: string; id?: string; name?: string; arguments?: Record<string, unknown> };
58
+ if (block.type !== "toolCall" || typeof block.id !== "string" || typeof block.name !== "string") continue;
59
+ out.push({ id: block.id, name: block.name, args: block.arguments ?? {} });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export function truncateText(text: string, max: number): string {
65
+ if (text.length <= max) return text;
66
+ return `${text.slice(0, max)}\n\n[Docket truncated ${text.length - max} chars]`;
67
+ }
68
+
69
+ function firstLine(text: string, fallback: string): string {
70
+ return text.trim().split("\n").find((line) => line.trim())?.trim() || fallback;
71
+ }
72
+
73
+ function firstHeading(text: string): string | undefined {
74
+ return text.split("\n").map((line) => line.trim()).find((line) => /^#{1,6}\s+\S/.test(line))?.replace(/^#{1,6}\s+/, "");
75
+ }
76
+
77
+ function asString(value: unknown): string | undefined {
78
+ return typeof value === "string" && value.length > 0 ? value : undefined;
79
+ }
80
+
81
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
82
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
83
+ }
84
+
85
+ function isArtifactKind(value: unknown): value is ArtifactKind {
86
+ return value === "command" || value === "error" || value === "file" || value === "code" || value === "prompt" || value === "response" || value === "checkpoint";
87
+ }
88
+
89
+ function diffStats(diff: string): string | undefined {
90
+ let additions = 0;
91
+ let removals = 0;
92
+ for (const line of diff.split("\n")) {
93
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
94
+ if (line.startsWith("-") && !line.startsWith("---")) removals++;
95
+ }
96
+ return additions || removals ? `+${additions}/-${removals}` : undefined;
97
+ }
98
+
99
+ export function formatArtifact(artifact: Artifact): string {
100
+ const lines = [
101
+ `# Docket artifact ${artifact.displayId}`,
102
+ `ref: ${artifact.ref}`,
103
+ `kind: ${artifact.kind}`,
104
+ artifact.entryId ? `entry: ${artifact.entryId}` : undefined,
105
+ artifact.subtitle ? `meta: ${artifact.subtitle}` : undefined,
106
+ "",
107
+ artifact.title,
108
+ "",
109
+ artifact.body,
110
+ ].filter((line): line is string => line !== undefined);
111
+ return lines.join("\n");
112
+ }
113
+
114
+ function shortCommand(command: string): string {
115
+ return command.replace(/\s+/g, " ").trim();
116
+ }
117
+
118
+ function artifactKindRank(kind: ArtifactKind): number {
119
+ return ["error", "command", "file", "code", "prompt", "response", "checkpoint"].indexOf(kind);
120
+ }
121
+
122
+ function makeArtifactId(kind: ArtifactKind, n: number): string {
123
+ return `${kind[0]}${n}`;
124
+ }
125
+
126
+ function extractCodeBlocks(text: string): Array<{ lang: string; code: string }> {
127
+ const blocks: Array<{ lang: string; code: string }> = [];
128
+ const re = /```([^\n`]*)\n([\s\S]*?)```/g;
129
+ let match: RegExpExecArray | null;
130
+ while ((match = re.exec(text)) !== null) {
131
+ blocks.push({ lang: match[1]?.trim() || "text", code: match[2] ?? "" });
132
+ }
133
+ return blocks;
134
+ }
135
+
136
+ function fileArtifactFromTool(call: ToolCallInfo, entry: any, cwd: string): Omit<Artifact, "id" | "displayId" | "ref"> | null {
137
+ const args = call.args;
138
+ const pathArg = asString(args.path) ?? asString(args.file) ?? asString(args.pattern);
139
+ if (!["read", "write", "edit", "grep", "find", "ls"].includes(call.name)) return null;
140
+
141
+ const op = call.name;
142
+ const target = pathArg ?? cwd;
143
+ const details = asRecord(entry.message?.details);
144
+ const diff = asString(details?.diff);
145
+ const firstChangedLine = typeof details?.firstChangedLine === "number" ? details.firstChangedLine : undefined;
146
+ const meta: string[] = [];
147
+ if (typeof args.offset === "number") meta.push(`offset ${args.offset}`);
148
+ if (typeof args.limit === "number") meta.push(`limit ${args.limit}`);
149
+ if (Array.isArray(args.edits)) meta.push(`${args.edits.length} edit(s)`);
150
+ if (diff) meta.push(diffStats(diff) ?? "diff");
151
+ if (asString(args.pattern)) meta.push(`pattern ${asString(args.pattern)}`);
152
+ const output = textFromContent(entry.message?.content);
153
+ const body = [
154
+ `operation: ${op}`,
155
+ `path: ${target}`,
156
+ `cwd: ${cwd}`,
157
+ `status: ${entry.message?.isError ? "error" : "ok"}`,
158
+ firstChangedLine ? `firstChangedLine: ${firstChangedLine}` : undefined,
159
+ "",
160
+ output,
161
+ diff ? "\n--- diff ---" : undefined,
162
+ diff,
163
+ ].filter((line): line is string => line !== undefined).join("\n");
164
+
165
+ return {
166
+ kind: "file",
167
+ title: `${op} ${target}`,
168
+ subtitle: meta.join(" · "),
169
+ body,
170
+ entryId: entry.id,
171
+ timestamp: Date.parse(entry.timestamp),
172
+ meta: { tool: op, args, ...(diff ? { diff } : {}), ...(firstChangedLine ? { firstChangedLine } : {}) },
173
+ };
174
+ }
175
+
176
+ function buildArtifacts(ctx: DocketRuntimeContext, config: ArtifactCatalogConfig): Artifact[] {
177
+ const branch = ctx.sessionManager.getBranch();
178
+ const calls = new Map<string, ToolCallInfo>();
179
+ const artifacts: Artifact[] = [];
180
+
181
+ const push = (artifact: Omit<Artifact, "id" | "displayId" | "ref">) => {
182
+ if (artifacts.length >= config.maxArtifacts) return;
183
+ const displayId = makeArtifactId(artifact.kind, artifacts.length + 1);
184
+ const entryKey = artifact.entryId ?? "session";
185
+ const sameEntryOrdinal = artifacts.filter((a) => a.kind === artifact.kind && (a.entryId ?? "session") === entryKey).length;
186
+ const ref = `${artifact.kind}:${entryKey}:${sameEntryOrdinal}`;
187
+ artifacts.push({ ...artifact, id: displayId, displayId, ref, body: truncateText(artifact.body, config.maxBodyChars) });
188
+ };
189
+
190
+ for (const entry of branch as any[]) {
191
+ if (entry.type === "custom" && entry.customType === CHECKPOINT_CUSTOM_TYPE) {
192
+ const data = entry.data as Partial<CheckpointIndexEntry> | undefined;
193
+ push({
194
+ kind: "checkpoint",
195
+ title: `checkpoint ${data?.id ?? entry.id}`,
196
+ subtitle: data?.mode ?? "handoff",
197
+ body: `checkpoint: ${data?.id ?? entry.id}\nfile: ${data?.file ?? "(unknown)"}\nnote: ${data?.note ?? ""}`,
198
+ entryId: entry.id,
199
+ timestamp: Date.parse(entry.timestamp),
200
+ meta: data as Record<string, unknown>,
201
+ });
202
+ continue;
203
+ }
204
+
205
+ if (entry.type !== "message") continue;
206
+ const msg = entry.message;
207
+ const timestamp = Date.parse(entry.timestamp);
208
+
209
+ if (msg?.role === "custom") {
210
+ const docketMeta = asRecord(asRecord(msg.details)?.docket);
211
+ const text = textFromContent(msg.content).trim();
212
+ if (docketMeta && text) {
213
+ const kind = isArtifactKind(docketMeta.kind) ? docketMeta.kind : "response";
214
+ const title = asString(docketMeta.title) ?? firstHeading(text) ?? firstLine(text, "extension output");
215
+ const subtitle = asString(docketMeta.subtitle) ?? asString(msg.customType) ?? "extension output";
216
+ push({
217
+ kind,
218
+ title,
219
+ subtitle,
220
+ body: text,
221
+ entryId: entry.id,
222
+ timestamp,
223
+ meta: { customType: msg.customType, docket: docketMeta },
224
+ });
225
+ }
226
+ continue;
227
+ }
228
+
229
+ if (msg?.role === "assistant") {
230
+ for (const call of toolCallsFromContent(msg.content)) {
231
+ calls.set(call.id, { ...call, entryId: entry.id, timestamp });
232
+ }
233
+
234
+ const text = textFromContent(msg.content).trim();
235
+ if (text) {
236
+ push({
237
+ kind: msg.errorMessage ? "error" : "response",
238
+ title: firstLine(text, "assistant response"),
239
+ subtitle: `${msg.provider ?? "model"}/${msg.model ?? "unknown"}`,
240
+ body: text,
241
+ entryId: entry.id,
242
+ timestamp,
243
+ meta: { provider: msg.provider, model: msg.model, stopReason: msg.stopReason },
244
+ });
245
+
246
+ for (const block of extractCodeBlocks(text)) {
247
+ push({
248
+ kind: "code",
249
+ title: `${block.lang} code block`,
250
+ subtitle: `${block.code.split("\n").length} lines`,
251
+ body: `\`\`\`${block.lang}\n${block.code}\`\`\``,
252
+ entryId: entry.id,
253
+ timestamp,
254
+ meta: { language: block.lang },
255
+ });
256
+ }
257
+ }
258
+ continue;
259
+ }
260
+
261
+ if (msg?.role === "user") {
262
+ const text = textFromContent(msg.content).trim();
263
+ if (text) {
264
+ push({
265
+ kind: "prompt",
266
+ title: firstLine(text, "user prompt"),
267
+ subtitle: new Date(timestamp).toLocaleString(),
268
+ body: text,
269
+ entryId: entry.id,
270
+ timestamp,
271
+ });
272
+ }
273
+ continue;
274
+ }
275
+
276
+ if (msg?.role === "toolResult") {
277
+ const call: ToolCallInfo = calls.get(msg.toolCallId) ?? {
278
+ id: msg.toolCallId,
279
+ name: msg.toolName,
280
+ args: {},
281
+ entryId: entry.id,
282
+ timestamp,
283
+ };
284
+ const output = textFromContent(msg.content);
285
+
286
+ if (call.name === "bash") {
287
+ const command = asString(call.args.command) ?? "(unknown command)";
288
+ push({
289
+ kind: "command",
290
+ title: `$ ${shortCommand(command)}`,
291
+ subtitle: `${msg.isError ? "failed" : "ok"} · cwd ${ctx.cwd}`,
292
+ body: [`cwd: ${ctx.cwd}`, `command: ${command}`, `status: ${msg.isError ? "error" : "ok"}`, "", output].join("\n"),
293
+ entryId: entry.id,
294
+ timestamp,
295
+ meta: { cwd: ctx.cwd, command, args: call.args },
296
+ });
297
+ }
298
+
299
+ const fileArtifact = fileArtifactFromTool(call, entry, ctx.cwd);
300
+ if (fileArtifact) push(fileArtifact);
301
+
302
+ if (msg.isError) {
303
+ push({
304
+ kind: "error",
305
+ title: `${call.name} failed`,
306
+ subtitle: asString(call.args.path) ?? asString(call.args.command) ?? "tool error",
307
+ body: [`tool: ${call.name}`, `args: ${JSON.stringify(call.args)}`, "", output].join("\n"),
308
+ entryId: entry.id,
309
+ timestamp,
310
+ meta: { tool: call.name, args: call.args },
311
+ });
312
+ }
313
+ continue;
314
+ }
315
+
316
+ if (msg?.role === "bashExecution") {
317
+ push({
318
+ kind: "command",
319
+ title: `$ ${shortCommand(msg.command ?? "")}`,
320
+ subtitle: `${msg.exitCode === 0 ? "ok" : "failed"} · user bash`,
321
+ body: [`command: ${msg.command}`, `exitCode: ${msg.exitCode}`, "", msg.output ?? ""].join("\n"),
322
+ entryId: entry.id,
323
+ timestamp,
324
+ meta: { command: msg.command, exitCode: msg.exitCode },
325
+ });
326
+ }
327
+ }
328
+
329
+ return artifacts.sort((a, b) => {
330
+ const time = (b.timestamp ?? 0) - (a.timestamp ?? 0);
331
+ if (time !== 0) return time;
332
+ return artifactKindRank(a.kind) - artifactKindRank(b.kind);
333
+ });
334
+ }
335
+
336
+ // One restart-oriented ordering: errors first (avoid repeats), then files, commands, and recent
337
+ // decisions. The interactive selector does the real pruning, so this only needs sane defaults.
338
+ const CHECKPOINT_KIND_ORDER: ArtifactKind[] = ["error", "file", "command", "response", "prompt", "code"];
339
+
340
+ function chooseCheckpointArtifacts(artifacts: Artifact[], limit: number): Artifact[] {
341
+ return artifacts
342
+ .filter((a) => CHECKPOINT_KIND_ORDER.includes(a.kind))
343
+ .sort((a, b) => CHECKPOINT_KIND_ORDER.indexOf(a.kind) - CHECKPOINT_KIND_ORDER.indexOf(b.kind) || (b.timestamp ?? 0) - (a.timestamp ?? 0))
344
+ .slice(0, limit);
345
+ }
346
+
347
+ function artifactRefId(artifact: Artifact): string {
348
+ return artifact.ref;
349
+ }
350
+
351
+ function buildArtifactReference(artifact: Artifact, cwd: string, options: { includeFileGuidance?: boolean } = {}): string {
352
+ const ref = artifactRefId(artifact);
353
+ if (artifact.kind === "file") {
354
+ const file = artifactFilePath(artifact, cwd);
355
+ const guidance = options.includeFileGuidance === false ? "" : " Use current file contents from disk if needed; do not paste file contents unless asked.";
356
+ return file
357
+ ? `Reference Docket ${ref}: file \`${path.relative(cwd, file) || file}\` (${artifact.title}).${guidance}`
358
+ : `Reference Docket ${ref}: file artifact \`${artifact.title}\`. ${artifact.subtitle}`;
359
+ }
360
+ if (artifact.kind === "command") return `Reference Docket ${ref}: command ${artifact.title} (${artifact.subtitle}). Use result only if relevant; avoid repeating failed command unless correcting it.`;
361
+ if (artifact.kind === "error") return `Reference Docket ${ref}: prior error ${artifact.title} (${artifact.subtitle}). Avoid repeating this failure unless explicitly fixing it.`;
362
+ if (artifact.kind === "prompt") return `Reference Docket ${ref}: prior user prompt \"${truncateText(artifact.title, 160)}\".`;
363
+ if (artifact.kind === "response") return `Reference Docket ${ref}: prior model response \"${truncateText(artifact.title, 160)}\".`;
364
+ if (artifact.kind === "code") return `Reference Docket ${ref}: ${artifact.title} (${artifact.subtitle}). Inspect artifact before reusing exact code.`;
365
+ return `Reference Docket ${ref}: ${artifact.title}. ${artifact.subtitle}`;
366
+ }
367
+
368
+ export function buildReferenceList(artifacts: Artifact[], cwd: string): string {
369
+ const lines = artifacts.map((artifact) => `- ${buildArtifactReference(artifact, cwd, { includeFileGuidance: false })}`);
370
+ if (artifacts.some((artifact) => artifact.kind === "file")) {
371
+ lines.push("", "File refs point to current disk paths; read current contents if needed. Do not paste file contents unless asked.");
372
+ }
373
+ return lines.join("\n");
374
+ }
375
+
376
+ export function artifactFilePath(artifact: Artifact, cwd: string): string | undefined {
377
+ if (artifact.kind !== "file") return undefined;
378
+ const args = (artifact.meta?.args ?? {}) as Record<string, unknown>;
379
+ const raw = asString(args.path) ?? asString(args.file) ?? artifact.body.match(/^path: (.+)$/m)?.[1];
380
+ if (!raw || raw === cwd) return undefined;
381
+ const cleaned = raw.startsWith("@") ? raw.slice(1) : raw;
382
+ return path.isAbsolute(cleaned) ? cleaned : path.resolve(cwd, cleaned);
383
+ }
384
+
385
+ async function inspectTextForArtifact(artifact: Artifact, cwd: string): Promise<{ title: string; text: string }> {
386
+ const file = artifactFilePath(artifact, cwd);
387
+ const diff = asString(artifact.meta?.diff);
388
+ if (diff) {
389
+ return {
390
+ title: file ? `${file} diff` : `${artifact.title} diff`,
391
+ text: [`# Docket diff view`, file ? `path: ${file}` : undefined, `artifact: ${artifact.ref} (${artifact.displayId}) ${artifact.title}`, `viewing: edit diff`, "", diff]
392
+ .filter((line): line is string => line !== undefined)
393
+ .join("\n"),
394
+ };
395
+ }
396
+ if (!file) return { title: artifact.title, text: formatArtifact(artifact) };
397
+ try {
398
+ const stat = await fs.stat(file);
399
+ if (!stat.isFile()) return { title: artifact.title, text: `${formatArtifact(artifact)}\n\n[Docket: ${file} is not a file]` };
400
+ const content = await fs.readFile(file, "utf8");
401
+ return {
402
+ title: file,
403
+ text: [`# Docket file view`, `path: ${file}`, `artifact: ${artifact.ref} (${artifact.displayId}) ${artifact.title}`, `viewing: current file contents`, "", content].join("\n"),
404
+ };
405
+ } catch (err) {
406
+ return { title: artifact.title, text: `${formatArtifact(artifact)}\n\n[Docket could not read current file: ${String(err)}]` };
407
+ }
408
+ }
409
+
410
+ export function createArtifactCatalog(
411
+ ctx: DocketRuntimeContext,
412
+ config: ArtifactCatalogConfig,
413
+ carryover: Artifact[] = [],
414
+ ): ArtifactCatalog {
415
+ const current = buildArtifacts(ctx, config);
416
+ const artifacts: Artifact[] = [...current, ...carryover];
417
+ const byId = new Map<string, Artifact>();
418
+ for (const artifact of artifacts) {
419
+ byId.set(artifact.displayId.toLowerCase(), artifact);
420
+ byId.set(artifact.ref.toLowerCase(), artifact);
421
+ }
422
+
423
+ return {
424
+ list() {
425
+ return artifacts;
426
+ },
427
+ find(idOrRef: string) {
428
+ return byId.get(idOrRef.toLowerCase());
429
+ },
430
+ reference(artifact: Artifact) {
431
+ return buildArtifactReference(artifact, ctx.cwd);
432
+ },
433
+ fullText(artifact: Artifact) {
434
+ return formatArtifact(artifact);
435
+ },
436
+ inspect(artifact: Artifact) {
437
+ return inspectTextForArtifact(artifact, ctx.cwd);
438
+ },
439
+ search(query: string) {
440
+ return searchArtifacts(query, artifacts);
441
+ },
442
+ selectForCheckpoint(limit: number) {
443
+ return chooseCheckpointArtifacts(current, limit);
444
+ },
445
+ checkpointPayload(selected: Artifact[]) {
446
+ return selected.map((artifact) => ({
447
+ ref: artifact.ref,
448
+ displayId: artifact.displayId,
449
+ kind: artifact.kind,
450
+ title: artifact.title,
451
+ subtitle: artifact.subtitle,
452
+ body: truncateText(artifact.body, 1600),
453
+ meta: artifact.meta ?? {},
454
+ }));
455
+ },
456
+ summary(artifact: Artifact) {
457
+ return {
458
+ displayId: artifact.displayId,
459
+ ref: artifact.ref,
460
+ kind: artifact.kind,
461
+ title: artifact.title,
462
+ subtitle: artifact.subtitle,
463
+ timestamp: artifact.timestamp,
464
+ };
465
+ },
466
+ };
467
+ }