@danypops/papyrus 0.33.4 → 0.34.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.
@@ -6,6 +6,7 @@ import { PROOF_TYPES } from "../../src/domain/checklist.ts";
6
6
  import type { GateResult } from "../../src/domain/gate.ts";
7
7
  import type { TaskExecutionPlan } from "../../src/task-execution.ts";
8
8
  import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
9
+ import type { NoteHistoryPage } from "../../src/domain/note-event.ts";
9
10
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
10
11
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
11
12
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
@@ -474,7 +475,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
474
475
  pi.registerTool({
475
476
  name: "notes",
476
477
  label: "Notes",
477
- description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
478
+ description: "Deferred human-intent inbox. ACTIONS: capture, list, show, history, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. history returns this note's own real append-only event log (captured/consumed/promoted/archived), not the generic cross-kind graph.history. PREFER `name` (the note's exact title) over `id` for show/history/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
478
479
  parameters: Type.Object({
479
480
  action: Type.String(),
480
481
  id: Type.Optional(Type.String()),
@@ -516,6 +517,12 @@ export function registerNotesTool(pi: ExtensionAPI): void {
516
517
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
517
518
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("notes.show", artifact));
518
519
  }
520
+ if (action === "history") {
521
+ const page = await callService<Record<string, unknown>, NoteHistoryPage>("notes.history", request);
522
+ const lines = page.events.map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`);
523
+ const output = lines.join("\n") || "No recorded history for this note.";
524
+ return text(output, createPreviewDetails("notes.history", "Note history", output));
525
+ }
519
526
  const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
520
527
  const operation = operations[action as keyof typeof operations];
521
528
  if (!operation) throw new Error(`unknown notes action: ${action}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -0,0 +1,80 @@
1
+ import type { Db } from "../db.ts";
2
+ import {
3
+ normalizeNoteHistoryQuery,
4
+ validateNoteEvent,
5
+ type AppendNoteEvent,
6
+ type NoteEvent,
7
+ type NoteEventType,
8
+ type NoteHistoryPage,
9
+ type NoteHistoryQuery,
10
+ } from "../domain/note-event.ts";
11
+ import type { NoteEventStore } from "../ports/note-event-store.ts";
12
+
13
+ interface NoteEventRow {
14
+ id: number;
15
+ note_id: string;
16
+ occurred_at: string;
17
+ event_type: NoteEventType;
18
+ actor: string;
19
+ source: string;
20
+ session_id: string | null;
21
+ reason: string | null;
22
+ related_id: string | null;
23
+ disposition: string | null;
24
+ event_schema_version: 1;
25
+ }
26
+
27
+ function mapRow(row: NoteEventRow): NoteEvent {
28
+ return {
29
+ id: row.id,
30
+ noteId: row.note_id,
31
+ occurredAt: row.occurred_at,
32
+ type: row.event_type,
33
+ actor: row.actor,
34
+ source: row.source,
35
+ ...(row.session_id === null ? {} : { sessionId: row.session_id }),
36
+ ...(row.reason === null ? {} : { reason: row.reason }),
37
+ ...(row.related_id === null ? {} : { relatedId: row.related_id }),
38
+ ...(row.disposition === null ? {} : { disposition: row.disposition }),
39
+ schemaVersion: row.event_schema_version,
40
+ };
41
+ }
42
+
43
+ export class SQLiteNoteEventStore implements NoteEventStore {
44
+ constructor(private readonly db: Db) {}
45
+
46
+ append(input: AppendNoteEvent): NoteEvent {
47
+ const event = validateNoteEvent(input);
48
+ const result = this.db.prepare(`
49
+ INSERT INTO note_events (
50
+ note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version
51
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
52
+ `).run(
53
+ event.noteId,
54
+ new Date().toISOString(),
55
+ event.type,
56
+ event.actor,
57
+ event.source,
58
+ event.sessionId ?? null,
59
+ event.reason ?? null,
60
+ event.relatedId ?? null,
61
+ event.disposition ?? null,
62
+ );
63
+ return mapRow(this.db.prepare("SELECT * FROM note_events WHERE id = ?").get(result.lastInsertRowid) as NoteEventRow);
64
+ }
65
+
66
+ history(noteId: string, query: NoteHistoryQuery = {}): NoteHistoryPage {
67
+ const { limit, direction, cursor } = normalizeNoteHistoryQuery(query);
68
+ const comparator = direction === "desc" ? "<" : ">";
69
+ const order = direction === "desc" ? "DESC" : "ASC";
70
+ const rows = this.db.prepare(`
71
+ SELECT * FROM note_events
72
+ WHERE note_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
73
+ ORDER BY occurred_at ${order}, id ${order}
74
+ LIMIT ?
75
+ `).all(...(cursor === undefined ? [noteId, limit + 1] : [noteId, cursor, limit + 1])) as NoteEventRow[];
76
+ const hasMore = rows.length > limit;
77
+ const events = rows.slice(0, limit).map(mapRow);
78
+ return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
79
+ }
80
+ }
package/src/cli.ts CHANGED
@@ -1274,7 +1274,7 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
1274
1274
  positional.push(argument);
1275
1275
  }
1276
1276
  const [action, id, target] = positional;
1277
- let result: CliArtifact | CliArtifact[];
1277
+ let result: CliArtifact | CliArtifact[] | import("./domain/note-event.ts").NoteHistoryPage;
1278
1278
  let human: string;
1279
1279
  if (action === "capture") {
1280
1280
  if (!id || target) throw new Error("notes capture requires exactly one request argument");
@@ -1288,6 +1288,13 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
1288
1288
  if (!id || target) throw new Error("notes show requires exactly one note id");
1289
1289
  result = await client.call("notes.show", { id, project_root: projectRoot }) as CliArtifact;
1290
1290
  human = `${artifactLabel(result)}\n\n${result.body ?? ""}`.trimEnd();
1291
+ } else if (action === "history") {
1292
+ if (!id || target) throw new Error("notes history requires exactly one note id");
1293
+ const page = await client.call<Record<string, unknown>, import("./domain/note-event.ts").NoteHistoryPage>("notes.history", { id, project_root: projectRoot, direction: "desc", ...(limit === undefined ? {} : { limit }) });
1294
+ result = page;
1295
+ human = page.events.length === 0
1296
+ ? `No recorded history for ${id}.`
1297
+ : [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
1291
1298
  } else if (action === "consume") {
1292
1299
  if (!id || target) throw new Error("notes consume requires exactly one note id");
1293
1300
  result = await client.call("notes.consume", { id, project_root: projectRoot, actor: "agent", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
@@ -1301,7 +1308,7 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
1301
1308
  result = await client.call("notes.archive", { id, disposition: target, project_root: projectRoot, actor: "human", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
1302
1309
  human = `Archived: ${artifactLabel(result)} · ${target}`;
1303
1310
  } else {
1304
- throw new Error("notes action must be capture, list, show, consume, promote, or archive");
1311
+ throw new Error("notes action must be capture, list, show, history, consume, promote, or archive");
1305
1312
  }
1306
1313
  return json ? JSON.stringify(result) : human;
1307
1314
  }
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 20;
10
+ export const SQLITE_SCHEMA_VERSION = 21;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -156,9 +156,10 @@ export const NOTE_BODY_MAX_CHARACTERS = 10_000;
156
156
  export const NOTE_TITLE_MAX_CHARACTERS = 80;
157
157
  export const NOTE_LIST_DEFAULT_LIMIT = 50;
158
158
  export const NOTE_LIST_MAX_LIMIT = 200;
159
- export const NOTE_HISTORY_MAX_EVENTS = 20;
160
159
  export const NOTE_PROVENANCE_MAX_LENGTH = 128;
161
160
  export const NOTE_REASON_MAX_CHARACTERS = 2_000;
161
+ export const NOTE_HISTORY_DEFAULT_LIMIT = 25;
162
+ export const NOTE_HISTORY_MAX_LIMIT = 100;
162
163
  /** Generic, kind-agnostic mutation event log bounds (doc/task/rule/skill share one log). */
163
164
  export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
164
165
  export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
package/src/db.ts CHANGED
@@ -266,6 +266,25 @@ CREATE TABLE IF NOT EXISTS task_leases (
266
266
  note TEXT
267
267
  );
268
268
  CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
269
+ CREATE TABLE IF NOT EXISTS note_events (
270
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
271
+ note_id TEXT NOT NULL REFERENCES artifacts(id),
272
+ occurred_at TEXT NOT NULL,
273
+ event_type TEXT NOT NULL,
274
+ actor TEXT NOT NULL,
275
+ source TEXT NOT NULL,
276
+ session_id TEXT,
277
+ reason TEXT,
278
+ related_id TEXT,
279
+ disposition TEXT,
280
+ event_schema_version INTEGER NOT NULL DEFAULT 1
281
+ );
282
+ CREATE INDEX IF NOT EXISTS note_events_history_idx ON note_events(note_id, occurred_at, id);
283
+ CREATE TRIGGER IF NOT EXISTS note_events_no_update BEFORE UPDATE ON note_events
284
+ BEGIN SELECT RAISE(ABORT, 'note_events are append-only'); END;
285
+ CREATE TRIGGER IF NOT EXISTS note_events_no_delete BEFORE DELETE ON note_events
286
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.note_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
287
+ BEGIN SELECT RAISE(ABORT, 'note_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
269
288
  `;
270
289
 
271
290
  const SEED_SQL = `
@@ -576,6 +595,66 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
576
595
  `);
577
596
  },
578
597
  },
598
+ {
599
+ version: 21,
600
+ name: "note-events",
601
+ // See domain/note-event.ts. Retires extra.noteHistory (a bounded array inside a mutable
602
+ // JSON field, which a later setExtra from a concurrent operation could silently overwrite)
603
+ // in favor of a real append-only table, mirroring task_events' already-proven shape.
604
+ up: (db) => {
605
+ db.exec(`
606
+ CREATE TABLE IF NOT EXISTS note_events (
607
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
608
+ note_id TEXT NOT NULL REFERENCES artifacts(id),
609
+ occurred_at TEXT NOT NULL,
610
+ event_type TEXT NOT NULL,
611
+ actor TEXT NOT NULL,
612
+ source TEXT NOT NULL,
613
+ session_id TEXT,
614
+ reason TEXT,
615
+ related_id TEXT,
616
+ disposition TEXT,
617
+ event_schema_version INTEGER NOT NULL DEFAULT 1
618
+ );
619
+ CREATE INDEX IF NOT EXISTS note_events_history_idx ON note_events(note_id, occurred_at, id);
620
+ CREATE TRIGGER IF NOT EXISTS note_events_no_update BEFORE UPDATE ON note_events
621
+ BEGIN SELECT RAISE(ABORT, 'note_events are append-only'); END;
622
+ CREATE TRIGGER IF NOT EXISTS note_events_no_delete BEFORE DELETE ON note_events
623
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.note_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
624
+ BEGIN SELECT RAISE(ABORT, 'note_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
625
+ `);
626
+ const rows = db.prepare("SELECT id, extra FROM artifacts WHERE kind = 'doc' AND subtype = 'note'").all() as Array<{ id: string; extra: string }>;
627
+ const insert = db.prepare(`
628
+ INSERT INTO note_events (note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version)
629
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
630
+ `);
631
+ const strip = db.prepare("UPDATE artifacts SET extra = ? WHERE id = ?");
632
+ for (const row of rows) {
633
+ const extra = JSON.parse(row.extra) as Record<string, unknown>;
634
+ const noteHistory = extra["noteHistory"];
635
+ if (Array.isArray(noteHistory)) {
636
+ for (const raw of noteHistory) {
637
+ const entry = raw as Record<string, unknown>;
638
+ insert.run(
639
+ row.id,
640
+ typeof entry["at"] === "string" ? entry["at"] : new Date().toISOString(),
641
+ typeof entry["action"] === "string" ? entry["action"] : "captured",
642
+ typeof entry["actor"] === "string" ? entry["actor"] : "system",
643
+ typeof entry["source"] === "string" ? entry["source"] : "unknown",
644
+ typeof entry["sessionId"] === "string" ? entry["sessionId"] : null,
645
+ typeof entry["reason"] === "string" ? entry["reason"] : null,
646
+ typeof entry["targetId"] === "string" ? entry["targetId"] : null,
647
+ typeof entry["disposition"] === "string" ? entry["disposition"] : null,
648
+ );
649
+ }
650
+ }
651
+ if ("noteHistory" in extra) {
652
+ const { noteHistory: _drop, ...rest } = extra;
653
+ strip.run(JSON.stringify(rest), row.id);
654
+ }
655
+ }
656
+ },
657
+ },
579
658
  ];
580
659
 
581
660
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Notes' own append-only event log, mirroring task-event.ts's proven shape. Replaces the
3
+ * previous extra.noteHistory: a bounded array inside a mutable JSON field, which a later
4
+ * setExtra from a concurrent operation could overwrite -- a real record can't do that.
5
+ */
6
+ import { NOTE_HISTORY_DEFAULT_LIMIT, NOTE_HISTORY_MAX_LIMIT, NOTE_PROVENANCE_MAX_LENGTH, NOTE_REASON_MAX_CHARACTERS } from "../constants.ts";
7
+
8
+ export const NOTE_EVENT_TYPES = ["captured", "consumed", "promoted", "archived"] as const;
9
+ export type NoteEventType = typeof NOTE_EVENT_TYPES[number];
10
+ export type NoteEventDirection = "asc" | "desc";
11
+
12
+ export interface NoteEvent {
13
+ id: number;
14
+ noteId: string;
15
+ occurredAt: string;
16
+ type: NoteEventType;
17
+ actor: string;
18
+ source: string;
19
+ sessionId?: string;
20
+ reason?: string;
21
+ /** Promotion target, when type is "promoted". */
22
+ relatedId?: string;
23
+ /** Archive disposition, or "promoted" -- mirrors the summary already kept at extra.disposition. */
24
+ disposition?: string;
25
+ schemaVersion: 1;
26
+ }
27
+
28
+ export interface AppendNoteEvent {
29
+ noteId: string;
30
+ type: NoteEventType;
31
+ actor: string;
32
+ source: string;
33
+ sessionId?: string;
34
+ reason?: string;
35
+ relatedId?: string;
36
+ disposition?: string;
37
+ }
38
+
39
+ export interface NoteHistoryQuery {
40
+ limit?: number;
41
+ cursor?: number;
42
+ direction?: NoteEventDirection;
43
+ }
44
+
45
+ export interface NoteHistoryPage {
46
+ events: NoteEvent[];
47
+ nextCursor?: number;
48
+ }
49
+
50
+ export function normalizeNoteHistoryQuery(query: NoteHistoryQuery = {}): Required<Pick<NoteHistoryQuery, "limit" | "direction">> & Pick<NoteHistoryQuery, "cursor"> {
51
+ const limit = query.limit ?? NOTE_HISTORY_DEFAULT_LIMIT;
52
+ if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_HISTORY_MAX_LIMIT) {
53
+ throw new Error(`note history limit must be between 1 and ${NOTE_HISTORY_MAX_LIMIT}`);
54
+ }
55
+ if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
56
+ throw new Error("note history cursor must be a positive integer");
57
+ }
58
+ if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
59
+ throw new Error("note history direction must be asc or desc");
60
+ }
61
+ return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
62
+ }
63
+
64
+ export function validateNoteEvent(event: AppendNoteEvent): AppendNoteEvent {
65
+ for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
66
+ if (!value || value.length > NOTE_PROVENANCE_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${NOTE_PROVENANCE_MAX_LENGTH} characters`);
67
+ }
68
+ if (event.sessionId !== undefined && event.sessionId.length > NOTE_PROVENANCE_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${NOTE_PROVENANCE_MAX_LENGTH} characters`);
69
+ if (event.reason !== undefined && event.reason.length > NOTE_REASON_MAX_CHARACTERS) throw new Error(`reason cannot exceed ${NOTE_REASON_MAX_CHARACTERS} characters`);
70
+ return event;
71
+ }
@@ -121,7 +121,10 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
121
121
  return typeof subtype === "string" ? subtype : undefined;
122
122
  }
123
123
 
124
- function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction = "status"): Artifact {
124
+ // No default action: linkDocument's own bug (both target and source checks silently defaulting to
125
+ // "status" here) was exactly what let a plain reference edge to a Task trip the tasks.* lifecycle
126
+ // guard, which is scoped to actual status changes only. Every call site now names its real action.
127
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction): Artifact {
125
128
  authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
126
129
  return document;
127
130
  }
@@ -191,7 +194,7 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
191
194
  }
192
195
 
193
196
  export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
194
- const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
197
+ const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "status"));
195
198
  const transition = DOCUMENT_TRANSITIONS[action];
196
199
  if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
197
200
  return artifacts.setStatus(id, transition.to, context)!;
@@ -215,10 +218,10 @@ export function updateDocument(artifacts: ArtifactStore, id: string, input: Upda
215
218
  }
216
219
 
217
220
  export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
218
- requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
221
+ requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "link"));
219
222
  const target = artifacts.get(targetId);
220
223
  if (!target) throw new Error(`target artifact "${targetId}" not found`);
221
- requireLocallyOwnedContent(requireMutableDocument(target, authority));
224
+ requireLocallyOwnedContent(requireMutableDocument(target, authority, "link"));
222
225
  artifacts.link({ from: id, relation, to: targetId }, context);
223
226
  return showDocument(artifacts, id);
224
227
  }
@@ -13,6 +13,7 @@
13
13
  * module's infrastructure" constraint.
14
14
  */
15
15
  import type { OperationDefinition } from "../module-registry.ts";
16
+ import type { NoteEventDirection } from "../domain/note-event.ts";
16
17
  import { Notes, type NoteDisposition } from "../note-service.ts";
17
18
 
18
19
  const MODULE_ID = "notes";
@@ -41,7 +42,7 @@ function optionalNumber(input: OperationInput, key: string): number | undefined
41
42
 
42
43
  /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
43
44
  export const NOTES_OPERATION_NAMES = [
44
- "notes.capture", "notes.list", "notes.show", "notes.consume", "notes.promote", "notes.archive",
45
+ "notes.capture", "notes.list", "notes.show", "notes.history", "notes.consume", "notes.promote", "notes.archive",
45
46
  ] as const;
46
47
 
47
48
  /** Registers every notes.* operation against one Notes instance. Behavior is unchanged from the prior inline handlers in src/service.ts. */
@@ -59,6 +60,9 @@ export function notesOperations(notes: Notes): OperationDefinition[] {
59
60
  text: optionalString(input, "text"), limit: optionalNumber(input, "limit"),
60
61
  })),
61
62
  define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
63
+ define("notes.history", (input: OperationInput) => notes.history(string(input, "id"), string(input, "project_root"), {
64
+ limit: optionalNumber(input, "limit"), cursor: optionalNumber(input, "cursor"), direction: optionalString(input, "direction") as NoteEventDirection | undefined,
65
+ })),
62
66
  define("notes.consume", (input: OperationInput) => notes.consume(string(input, "id"), {
63
67
  projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
64
68
  sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  NOTE_BODY_MAX_CHARACTERS,
3
- NOTE_HISTORY_MAX_EVENTS,
4
3
  NOTE_LIST_DEFAULT_LIMIT,
5
4
  NOTE_LIST_MAX_LIMIT,
6
5
  NOTE_PROVENANCE_MAX_LENGTH,
@@ -9,6 +8,8 @@ import {
9
8
  TASK_PROJECT_ROOT_MAX_LENGTH,
10
9
  } from "./constants.ts";
11
10
  import type { Artifact } from "./domain/artifact.ts";
11
+ import type { AppendNoteEvent, NoteEventType, NoteHistoryPage, NoteHistoryQuery } from "./domain/note-event.ts";
12
+ import { InMemoryNoteEventStore, type NoteEventStore } from "./ports/note-event-store.ts";
12
13
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
14
  import type { ArtifactStore } from "./ports/artifact-store.ts";
14
15
 
@@ -41,17 +42,6 @@ export interface ArchiveNoteInput extends NoteProvenance {
41
42
  disposition: NoteDisposition;
42
43
  }
43
44
 
44
- interface NoteHistoryEvent {
45
- action: "captured" | "consumed" | "promoted" | "archived";
46
- at: string;
47
- actor: string;
48
- source: string;
49
- sessionId?: string;
50
- reason?: string;
51
- targetId?: string;
52
- disposition?: NoteDisposition | "promoted";
53
- }
54
-
55
45
  function requiredBounded(value: string, field: string, maximum: number): string {
56
46
  const normalized = value.trim();
57
47
  if (!normalized) throw new Error(`${field} is required`);
@@ -70,7 +60,7 @@ function noteTitle(body: string, requested?: string): string {
70
60
  return firstLine.slice(0, NOTE_TITLE_MAX_CHARACTERS) || "Deferred note";
71
61
  }
72
62
 
73
- function provenance(input: NoteProvenance, defaults: { actor: string; source: string }): Omit<NoteHistoryEvent, "action" | "at"> {
63
+ function provenance(input: NoteProvenance, defaults: { actor: string; source: string }): Omit<AppendNoteEvent, "noteId" | "type" | "relatedId" | "disposition"> {
74
64
  return {
75
65
  actor: optionalBounded(input.actor, "note actor", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.actor,
76
66
  source: optionalBounded(input.source, "note source", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.source,
@@ -79,44 +69,26 @@ function provenance(input: NoteProvenance, defaults: { actor: string; source: st
79
69
  };
80
70
  }
81
71
 
82
- function history(artifact: Artifact): NoteHistoryEvent[] {
83
- const value = artifact.extra["noteHistory"];
84
- if (!Array.isArray(value)) return [];
85
- return value.filter((entry): entry is NoteHistoryEvent => typeof entry === "object" && entry !== null && !Array.isArray(entry));
86
- }
87
-
88
- function appendHistory(artifact: Artifact, event: NoteHistoryEvent): Record<string, unknown> {
89
- return {
90
- ...artifact.extra,
91
- noteHistory: [...history(artifact), event].slice(-NOTE_HISTORY_MAX_EVENTS),
92
- };
93
- }
94
-
95
- function event(action: NoteHistoryEvent["action"], input: NoteProvenance, extra: Partial<NoteHistoryEvent> = {}): NoteHistoryEvent {
96
- return {
97
- action,
98
- at: new Date().toISOString(),
99
- ...provenance(input, { actor: action === "captured" ? "human" : "agent", source: "notes" }),
100
- ...extra,
101
- };
102
- }
103
-
104
72
  export class Notes {
105
- constructor(private readonly artifacts: ArtifactStore) {}
73
+ constructor(
74
+ private readonly artifacts: ArtifactStore,
75
+ private readonly events: NoteEventStore = new InMemoryNoteEventStore(),
76
+ ) {}
106
77
 
107
78
  capture(input: CaptureNoteInput): Artifact {
108
79
  const projectRoot = requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
109
80
  const body = requiredBounded(input.body, "note body", NOTE_BODY_MAX_CHARACTERS);
110
- const captured = event("captured", input);
111
- return this.artifacts.create({
81
+ const created = this.artifacts.create({
112
82
  kind: "doc",
113
83
  subtype: NOTE_SUBTYPE,
114
84
  status: "draft",
115
85
  title: noteTitle(body, input.title),
116
86
  body,
117
87
  labels: ["note", "inbox"],
118
- extra: { projectRoot, noteHistory: [captured] },
88
+ extra: { projectRoot },
119
89
  });
90
+ this.appendEvent(created.id, "captured", input, { actor: "human", source: "notes" });
91
+ return created;
120
92
  }
121
93
 
122
94
  list(input: ListNotesInput): Artifact[] {
@@ -141,6 +113,13 @@ export class Notes {
141
113
  return this.artifacts.get(id, { tree: true })!;
142
114
  }
143
115
 
116
+ /** Real append-only event history for this note -- see domain/note-event.ts. */
117
+ history(id: string, projectRoot: string, query?: NoteHistoryQuery): NoteHistoryPage {
118
+ const note = this.requireNote(id);
119
+ this.requireProject(note, projectRoot);
120
+ return this.events.history(id, query);
121
+ }
122
+
144
123
  consume(id: string, input: NoteProvenance & { projectRoot: string }): Artifact {
145
124
  const atomic = requireAtomicArtifactStore(this.artifacts);
146
125
  return atomic.atomic(() => {
@@ -148,7 +127,7 @@ export class Notes {
148
127
  this.requireProject(note, input.projectRoot);
149
128
  if (note.status === "archived") throw new Error("cannot consume an archived note");
150
129
  if (note.status === "active") return this.artifacts.get(id, { tree: true })!;
151
- this.artifacts.setExtra(id, appendHistory(note, event("consumed", input)));
130
+ this.appendEvent(id, "consumed", input, { actor: "agent", source: "notes" });
152
131
  this.artifacts.setStatus(id, "active");
153
132
  return this.artifacts.get(id, { tree: true })!;
154
133
  });
@@ -162,12 +141,12 @@ export class Notes {
162
141
  if (note.status === "archived") throw new Error("cannot promote an archived note");
163
142
  if (targetId === id) throw new Error("a note cannot promote to itself");
164
143
  if (!this.artifacts.get(targetId)) throw new Error(`promotion target "${targetId}" not found`);
165
- const promoted = event("promoted", input, { disposition: "promoted", targetId });
166
- const disposition = { kind: "promoted", targetId, ...(promoted.reason ? { reason: promoted.reason } : {}) };
144
+ const reason = optionalBounded(input.reason, "note reason", NOTE_REASON_MAX_CHARACTERS);
167
145
  this.artifacts.link({ from: id, relation: "relates_to", to: targetId });
146
+ this.appendEvent(id, "promoted", input, { actor: "agent", source: "notes" }, { relatedId: targetId, disposition: "promoted" });
168
147
  this.artifacts.setExtra(id, {
169
- ...appendHistory(note, promoted),
170
- disposition,
148
+ ...note.extra,
149
+ disposition: { kind: "promoted", targetId, ...(reason ? { reason } : {}) },
171
150
  });
172
151
  this.artifacts.setStatus(id, "archived");
173
152
  return this.artifacts.get(id, { tree: true })!;
@@ -181,17 +160,27 @@ export class Notes {
181
160
  const note = this.requireNote(id);
182
161
  this.requireProject(note, input.projectRoot);
183
162
  if (note.status === "archived") throw new Error("note is already archived");
184
- const archived = event("archived", input, { disposition: input.disposition });
185
- const details = { kind: input.disposition, ...(archived.reason ? { reason: archived.reason } : {}) };
163
+ const reason = optionalBounded(input.reason, "note reason", NOTE_REASON_MAX_CHARACTERS);
164
+ this.appendEvent(id, "archived", input, { actor: "agent", source: "notes" }, { disposition: input.disposition });
186
165
  this.artifacts.setExtra(id, {
187
- ...appendHistory(note, archived),
188
- disposition: details,
166
+ ...note.extra,
167
+ disposition: { kind: input.disposition, ...(reason ? { reason } : {}) },
189
168
  });
190
169
  this.artifacts.setStatus(id, "archived");
191
170
  return this.artifacts.get(id, { tree: true })!;
192
171
  });
193
172
  }
194
173
 
174
+ private appendEvent(
175
+ noteId: string,
176
+ type: NoteEventType,
177
+ input: NoteProvenance,
178
+ defaults: { actor: string; source: string },
179
+ extra: Partial<Pick<AppendNoteEvent, "relatedId" | "disposition">> = {},
180
+ ): void {
181
+ this.events.append({ noteId, type, ...provenance(input, defaults), ...extra });
182
+ }
183
+
195
184
  private requireNote(id: string): Artifact {
196
185
  const artifact = this.artifacts.get(id);
197
186
  if (!artifact || artifact.kind !== "doc" || artifact.subtype !== NOTE_SUBTYPE) throw new Error(`note "${id}" not found`);
package/src/ops.ts CHANGED
@@ -391,10 +391,10 @@ export function restoreArtifact(db: Db, id: string, context?: ArtifactEventConte
391
391
  * Deletes, in FK-safe order, every row across every table that can reference artifacts(id)
392
392
  * (see the grep-verified list in domain/artifact-trash.ts's design comment): edges (both
393
393
  * directions), task_focus, task_scopes, task_views (by root_task_id), graph_projection_
394
- * identities, artifact_scopes, then task_events and artifact_events -- the latter two
395
- * succeed only because the artifact_trash row placed here by trashArtifact still exists
396
- * with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
397
- * artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
394
+ * identities, artifact_scopes, then task_events, note_events, and artifact_events -- the
395
+ * latter three succeed only because the artifact_trash row placed here by trashArtifact
396
+ * still exists with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
397
+ * note_events_no_delete / artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
398
398
  * own row (it is itself a child of artifacts via a real FK, so it must go before artifacts,
399
399
  * but only after the event tables that depend on its continued presence), and artifacts
400
400
  * itself last of all. One artifact at a time in its own transaction, so one failure never
@@ -413,6 +413,7 @@ export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().t
413
413
  db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
414
414
  db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
415
415
  db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
416
+ db.prepare("DELETE FROM note_events WHERE note_id = ?").run(id);
416
417
  db.prepare("DELETE FROM artifact_events WHERE artifact_id = ?").run(id);
417
418
  db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
418
419
  db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
@@ -0,0 +1,38 @@
1
+ import {
2
+ normalizeNoteHistoryQuery,
3
+ validateNoteEvent,
4
+ type AppendNoteEvent,
5
+ type NoteEvent,
6
+ type NoteHistoryPage,
7
+ type NoteHistoryQuery,
8
+ } from "../domain/note-event.ts";
9
+
10
+ export interface NoteEventStore {
11
+ append(event: AppendNoteEvent): NoteEvent;
12
+ history(noteId: string, query?: NoteHistoryQuery): NoteHistoryPage;
13
+ }
14
+
15
+ export class InMemoryNoteEventStore implements NoteEventStore {
16
+ private events: NoteEvent[] = [];
17
+ private nextId = 1;
18
+
19
+ append(event: AppendNoteEvent): NoteEvent {
20
+ const stored: NoteEvent = {
21
+ ...validateNoteEvent(event),
22
+ id: this.nextId++,
23
+ occurredAt: new Date().toISOString(),
24
+ schemaVersion: 1,
25
+ };
26
+ this.events.push(stored);
27
+ return stored;
28
+ }
29
+
30
+ history(noteId: string, query: NoteHistoryQuery = {}): NoteHistoryPage {
31
+ const { direction, limit, cursor } = normalizeNoteHistoryQuery(query);
32
+ const ordered = this.events
33
+ .filter((event) => event.noteId === noteId && (cursor === undefined || (direction === "desc" ? event.id < cursor : event.id > cursor)))
34
+ .sort((left, right) => direction === "desc" ? right.id - left.id : left.id - right.id);
35
+ const events = ordered.slice(0, limit);
36
+ return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
37
+ }
38
+ }
package/src/service.ts CHANGED
@@ -8,6 +8,7 @@ import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-s
8
8
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
9
9
  import { SQLiteTaskLeaseStore } from "./adapters/sqlite-task-lease-store.ts";
10
10
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
11
+ import { SQLiteNoteEventStore } from "./adapters/sqlite-note-event-store.ts";
11
12
  import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
12
13
  import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
13
14
  import type { CreateArtifactInput } from "./domain/artifact.ts";
@@ -179,7 +180,7 @@ function lifecycleAuthorityClaim(owner: "docs" | "rules" | "skills" | "playbooks
179
180
  };
180
181
  }
181
182
 
182
- function createAuthorityRegistry(): AuthorityRegistry {
183
+ export function createAuthorityRegistry(): AuthorityRegistry {
183
184
  const authority = new AuthorityRegistry();
184
185
  authority.claimAll([
185
186
  notesAuthorityClaim,
@@ -381,6 +382,7 @@ function handlers(
381
382
  "notes.capture": forwardToModule("notes.capture"),
382
383
  "notes.list": forwardToModule("notes.list"),
383
384
  "notes.show": forwardToModule("notes.show"),
385
+ "notes.history": forwardToModule("notes.history"),
384
386
  "notes.consume": forwardToModule("notes.consume"),
385
387
  "notes.promote": forwardToModule("notes.promote"),
386
388
  "notes.archive": forwardToModule("notes.archive"),
@@ -457,7 +459,8 @@ export function createPapyrusService(path: string): PapyrusService {
457
459
  const scopes = new SQLiteTaskScopeStore(db);
458
460
  const leases = new SQLiteTaskLeaseStore(db);
459
461
  const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
460
- const notes = new Notes(artifacts);
462
+ const noteEvents = new SQLiteNoteEventStore(db);
463
+ const notes = new Notes(artifacts, noteEvents);
461
464
  const projections = new SQLiteGraphProjectionStore(db);
462
465
  const artifactScopes = new SQLiteArtifactScopeStore(db);
463
466
  const logs = new Logs(new SQLiteLogStore(db));