@danypops/papyrus 0.13.5 → 0.14.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.
@@ -172,9 +172,24 @@ export interface MessageHistoryTree {
172
172
  * estimate the conversation's context contribution AND surface branches explored via /tree
173
173
  * that are no longer on the active path -- content that cost real tokens to generate but is
174
174
  * NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES):
175
- * a session file is external, mutable state, and this deliberately
176
- * hardens past a confirmed real gap in Pi's own getBranch() (no cycle guard at all) rather
177
- * than assuming the tree can never be malformed.
175
+ * a session file is external, mutable state, and this deliberately hardens past a confirmed
176
+ * real gap in Pi's own getBranch() (no cycle guard at all) rather than assuming the tree can
177
+ * never be malformed.
178
+ *
179
+ * `activeEntryIds` MUST come from ctx.sessionManager.buildContextEntries(), not getBranch().
180
+ * getBranch()'s own docstring says it "[i]ncludes all entry types... Use buildSessionContext()
181
+ * to get the resolved messages for the LLM" -- it does not skip entries a real compaction has
182
+ * already summarized away. A real session with 3 compactions confirmed using getBranch() here
183
+ * overcounts activeTokens by over 13x, since every pre-compaction message still reads as
184
+ * "active". buildContextEntries() is Pi's own compaction-aware entry list: the latest
185
+ * compaction entry, its kept entries from firstKeptEntryId onward, and everything after.
186
+ *
187
+ * `branchEntryIds` (optional) is the full raw current-path id set (getBranch()'s own output).
188
+ * When given, an entry on the branch path but excluded from activeEntryIds is labeled
189
+ * "(compacted)" rather than the less accurate "(inactive branch)", which is reserved for
190
+ * entries not on the current path at all (a genuinely abandoned /tree branch). Omitting it
191
+ * preserves the simpler binary active/inactive-branch labeling for callers that only have one
192
+ * set to give (e.g. tests).
178
193
  */
179
194
  interface WalkFrame {
180
195
  node: SessionTreeNodeLike;
@@ -190,7 +205,7 @@ interface WalkFrame {
190
205
  * JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
191
206
  * bound entirely.
192
207
  */
193
- export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
208
+ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>, branchEntryIds?: ReadonlySet<string>): MessageHistoryTree {
194
209
  const visited = new Set<string>();
195
210
  let truncated = false;
196
211
  let activeTokens = 0;
@@ -222,12 +237,13 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
222
237
  const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
223
238
  const isActive = activeEntryIds.has(entry.id);
224
239
  if (isActive) activeTokens += tokens;
240
+ const isOnBranch = branchEntryIds ? branchEntryIds.has(entry.id) : isActive; // no branch set given -- fall back to the old binary active/inactive-branch label
225
241
 
226
242
  const children = childItemsByParent.get(index) ?? [];
227
243
  if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
228
244
 
229
245
  const item: ContextSegmentItem = {
230
- label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
246
+ label: isActive ? entryLabel(entry) : isOnBranch ? `${entryLabel(entry)} (compacted)` : `${entryLabel(entry)} (inactive branch)`,
231
247
  estimatedTokens: tokens,
232
248
  ...(children.length > 0 ? { children } : {}),
233
249
  };
@@ -27,7 +27,7 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
27
27
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
28
28
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
29
29
  import { buildContextInjection } from "./context-injection-telemetry.ts";
30
- import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
30
+ import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
31
31
  import { showContextView } from "./context-view.ts";
32
32
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
33
33
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
@@ -160,6 +160,7 @@ export default async function (pi: ExtensionAPI) {
160
160
  let contextInjectionSequence = 0;
161
161
  const contextInjectionProducerId = randomUUID();
162
162
  let previousContextInjectionFingerprint: string | undefined;
163
+ let logTurnSequence = 0;
163
164
  // Cached from the most recent before_agent_start observation: Pi's own base system prompt
164
165
  // is only ever visible transiently inside that hook's event.systemPrompt, so /context
165
166
  // reuses the size buildContextInjection already computes every turn rather than going
@@ -200,6 +201,36 @@ export default async function (pi: ExtensionAPI) {
200
201
  }
201
202
  };
202
203
 
204
+ // ── Post-mortem AND live: every settled turn, log a real context-usage snapshot ────
205
+ // Deliberately lean (real usage + budget numbers only, no full segment breakdown --
206
+ // that stays /context's job on demand) so this stays cheap enough to run every turn:
207
+ // no extra daemon round-trips beyond the one logs.append call, no session tree walk.
208
+ const PI_SESSION_CONTEXT_LOG_SOURCE = "pi-session-context";
209
+ const logSessionContextSnapshot = async (ctx: ExtensionContext): Promise<void> => {
210
+ try {
211
+ const usage = ctx.getContextUsage?.();
212
+ if (!usage || usage.tokens === null) return; // nothing real to report yet (e.g. before the first assistant turn, or right after compaction)
213
+ const totalTokens = usage.tokens;
214
+ const sessionId = ctx.sessionManager.getSessionId();
215
+ const effectiveBudget = Math.max(0, usage.contextWindow - DEFAULT_RESERVE_TOKENS);
216
+ const percentOfBudget = effectiveBudget > 0 ? Math.round((totalTokens / effectiveBudget) * 1000) / 10 : null;
217
+ await callService("logs.append", {
218
+ source_id: PI_SESSION_CONTEXT_LOG_SOURCE,
219
+ source_label: "Pi session context usage",
220
+ project_root: ctx.cwd,
221
+ level: "info",
222
+ message: `context usage: ${totalTokens} tok / ${effectiveBudget} tok budget (${percentOfBudget}%)`,
223
+ operation_id: `${sessionId}:${++logTurnSequence}`,
224
+ session_id: sessionId,
225
+ fields: { totalTokens, contextWindow: usage.contextWindow, effectiveBudget, percentOfBudget },
226
+ });
227
+ } catch {
228
+ // The daemon may be unavailable during startup, reload, or shutdown -- a missed
229
+ // snapshot is not worth surfacing to the user, matching every other best-effort
230
+ // per-turn daemon call in this extension.
231
+ }
232
+ };
233
+
203
234
  // ── Low-level graph-store tools ────────────────────────────────────
204
235
 
205
236
  pi.registerTool({
@@ -431,8 +462,17 @@ export default async function (pi: ExtensionAPI) {
431
462
  // Real tree (not just the linear current-branch path): surfaces content sitting in an
432
463
  // abandoned /tree branch, which cost real tokens to generate but isn't in context now.
433
464
  const tree = ctx.sessionManager.getTree() as unknown as SessionTreeNodeLike[];
434
- const activeEntryIds = new Set((ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]).map((entry) => entry.id));
435
- const messageHistory = buildMessageHistoryTree(tree, activeEntryIds);
465
+ // buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the
466
+ // current path including everything a real compaction has already summarized away.
467
+ // A session with 3 real compactions confirmed this made "active" message-history
468
+ // tokens overcount the real total by over 13x -- getBranch()'s own docstring already
469
+ // says as much ("Use buildSessionContext() to get the resolved messages for the
470
+ // LLM"); buildContextEntries() is the compaction-aware entry list matching what the
471
+ // LLM actually sees (the latest compaction entry itself, plus kept entries from its
472
+ // firstKeptEntryId onward, plus everything after -- older summarized entries omitted).
473
+ const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as unknown as SessionEntryLike[]).map((entry) => entry.id));
474
+ const branchEntryIds = new Set((ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]).map((entry) => entry.id));
475
+ const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
436
476
  const breakdown = buildContextBreakdown({
437
477
  totalTokens: usage?.tokens ?? null,
438
478
  contextWindow: ctx.model?.contextWindow ?? null,
@@ -492,7 +532,10 @@ export default async function (pi: ExtensionAPI) {
492
532
  }
493
533
  });
494
534
  pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
495
- pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
535
+ pi.on("agent_settled", async (_event, ctx) => {
536
+ await driveActiveTasks(ctx);
537
+ await logSessionContextSnapshot(ctx);
538
+ });
496
539
 
497
540
  // ── "Are we there yet?" — inject active tasks into every turn ──────
498
541
  // The agent sees its open work items every turn. If there are rejected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.5",
3
+ "version": "0.14.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,88 @@
1
+ import type { Db } from "../db.ts";
2
+ import type { JsonValue, LogEntry, LogLevel, LogSource } from "../domain/log-entry.ts";
3
+ import type { LogStore } from "../ports/log-store.ts";
4
+
5
+ interface SourceRow {
6
+ id: string;
7
+ label: string;
8
+ project_root: string | null;
9
+ created_at: string;
10
+ }
11
+
12
+ interface EntryRow {
13
+ id: string;
14
+ source_id: string;
15
+ occurred_at: string;
16
+ level: string;
17
+ message: string;
18
+ truncated: number;
19
+ fields_json: string;
20
+ operation_id: string;
21
+ session_id: string | null;
22
+ }
23
+
24
+ function toSource(row: SourceRow): LogSource {
25
+ return { id: row.id, label: row.label, projectRoot: row.project_root, createdAt: row.created_at };
26
+ }
27
+
28
+ function toEntry(row: EntryRow): LogEntry {
29
+ return {
30
+ id: row.id,
31
+ sourceId: row.source_id,
32
+ occurredAt: row.occurred_at,
33
+ level: row.level as LogLevel,
34
+ message: row.message,
35
+ truncated: row.truncated === 1,
36
+ fields: JSON.parse(row.fields_json) as JsonValue,
37
+ operationId: row.operation_id,
38
+ sessionId: row.session_id ?? undefined,
39
+ };
40
+ }
41
+
42
+ export class SQLiteLogStore implements LogStore {
43
+ constructor(private readonly db: Db) {}
44
+
45
+ ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource {
46
+ const existing = this.db.prepare("SELECT id, label, project_root, created_at FROM log_sources WHERE id = ?").get(sourceId) as SourceRow | undefined;
47
+ if (existing) return toSource(existing);
48
+ const createdAt = new Date().toISOString();
49
+ this.db.prepare("INSERT INTO log_sources (id, label, project_root, created_at) VALUES (?, ?, ?, ?)").run(sourceId, label, projectRoot, createdAt);
50
+ return { id: sourceId, label, projectRoot, createdAt };
51
+ }
52
+
53
+ findEntryByOperationId(sourceId: string, operationId: string): LogEntry | undefined {
54
+ const row = this.db.prepare(
55
+ "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? AND operation_id = ?",
56
+ ).get(sourceId, operationId) as EntryRow | undefined;
57
+ return row ? toEntry(row) : undefined;
58
+ }
59
+
60
+ insertEntry(entry: LogEntry): void {
61
+ this.db.prepare(
62
+ `INSERT INTO log_entries (id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
64
+ ).run(
65
+ entry.id, entry.sourceId, entry.occurredAt, entry.level, entry.message,
66
+ entry.truncated ? 1 : 0, JSON.stringify(entry.fields), entry.operationId, entry.sessionId ?? null,
67
+ );
68
+ }
69
+
70
+ entriesForSource(sourceId: string): readonly LogEntry[] {
71
+ const rows = this.db.prepare(
72
+ "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? ORDER BY occurred_at, id",
73
+ ).all(sourceId) as EntryRow[];
74
+ return rows.map(toEntry);
75
+ }
76
+
77
+ trimSource(sourceId: string, maxEntries: number): number {
78
+ const countRow = this.db.prepare("SELECT COUNT(*) as count FROM log_entries WHERE source_id = ?").get(sourceId) as { count: number };
79
+ const excess = countRow.count - maxEntries;
80
+ if (excess <= 0) return 0;
81
+ this.db.prepare(
82
+ `DELETE FROM log_entries WHERE id IN (
83
+ SELECT id FROM log_entries WHERE source_id = ? ORDER BY occurred_at, id LIMIT ?
84
+ )`,
85
+ ).run(sourceId, excess);
86
+ return excess;
87
+ }
88
+ }
package/src/cli.ts CHANGED
@@ -112,6 +112,8 @@ const USAGE = `Usage:
112
112
  papyrus notes consume <id> [--reason <reason>] [--json]
113
113
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
114
114
  papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
115
+ papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
116
+ papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
115
117
  papyrus tasks plan [--session-id <id>] [--json]
116
118
  papyrus tasks graph [--session-id <id>] [--json]
117
119
  papyrus tasks active [--session-id <id>] [--json]
@@ -873,6 +875,71 @@ export async function runGraphProjectionCli(args: string[], client: TaskCliClien
873
875
  throw new Error("graph-projection action must be apply or checkpoint");
874
876
  }
875
877
 
878
+ export async function runLogCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
879
+ const json = args.includes("--json");
880
+ const positional: string[] = [];
881
+ let source: string | undefined;
882
+ let sourceLabel: string | undefined;
883
+ let level: string | undefined;
884
+ let message: string | undefined;
885
+ let operationId: string | undefined;
886
+ let sessionId: string | undefined;
887
+ let occurredAt: string | undefined;
888
+ let since: string | undefined;
889
+ let limit: number | undefined;
890
+ let fields: Record<string, unknown> | undefined;
891
+ let global = false;
892
+ for (let index = 0; index < args.length; index++) {
893
+ const argument = args[index]!;
894
+ if (argument === "--json") continue;
895
+ if (argument === "--global") { global = true; continue; }
896
+ if (argument === "--fields-json") { fields = parseJsonObjectFlag(args[++index], "--fields-json"); continue; }
897
+ if (["--source", "--source-label", "--level", "--message", "--operation-id", "--session-id", "--occurred-at", "--since", "--limit"].includes(argument)) {
898
+ const value = args[++index];
899
+ if (value === undefined) throw new Error(`${argument} requires a value`);
900
+ if (argument === "--source") source = value;
901
+ else if (argument === "--source-label") sourceLabel = value;
902
+ else if (argument === "--level") level = value;
903
+ else if (argument === "--message") message = value;
904
+ else if (argument === "--operation-id") operationId = value;
905
+ else if (argument === "--session-id") sessionId = value;
906
+ else if (argument === "--occurred-at") occurredAt = value;
907
+ else if (argument === "--since") since = value;
908
+ else {
909
+ limit = Number(value);
910
+ if (!Number.isInteger(limit)) throw new Error("--limit requires an integer");
911
+ }
912
+ continue;
913
+ }
914
+ if (argument.startsWith("--")) throw new Error(`unknown log option ${argument}`);
915
+ positional.push(argument);
916
+ }
917
+ const [action] = positional;
918
+ if (action === "append") {
919
+ if (!source) throw new Error("log append requires --source");
920
+ if (!level) throw new Error("log append requires --level");
921
+ if (!message) throw new Error("log append requires --message");
922
+ if (!operationId) throw new Error("log append requires --operation-id");
923
+ const result = await client.call("logs.append", {
924
+ source_id: source, ...(sourceLabel ? { source_label: sourceLabel } : {}),
925
+ ...(global ? {} : { project_root: projectRoot }),
926
+ level, message, operation_id: operationId,
927
+ ...(fields ? { fields } : {}), ...(sessionId ? { session_id: sessionId } : {}), ...(occurredAt ? { occurred_at: occurredAt } : {}),
928
+ });
929
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
930
+ }
931
+ if (action === "query") {
932
+ if (!source) throw new Error("log query requires --source");
933
+ const result = await client.call<Record<string, unknown>, { entries: unknown[]; truncated: boolean }>("logs.query", {
934
+ source_id: source, ...(since ? { since } : {}), ...(level ? { level } : {}), ...(limit === undefined ? {} : { limit }),
935
+ });
936
+ if (json) return JSON.stringify(result);
937
+ const lines = result.entries.map((entry) => JSON.stringify(entry));
938
+ return [...lines, result.truncated ? `(truncated -- more entries exist beyond this page)` : `(${lines.length} entries)`].join("\n");
939
+ }
940
+ throw new Error("log action must be append or query");
941
+ }
942
+
876
943
  export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
877
944
  const json = args.includes("--json");
878
945
  const positional: string[] = [];
@@ -1272,6 +1339,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1272
1339
  console.log(await runNoteCli(args.slice(1), client));
1273
1340
  return;
1274
1341
  }
1342
+ if (command === "log") {
1343
+ const client = await connectPapyrusClient();
1344
+ console.log(await runLogCli(args.slice(1), client));
1345
+ return;
1346
+ }
1275
1347
  if (command === "migrate") {
1276
1348
  const client = await connectPapyrusClient();
1277
1349
  console.log(await runMigrationCli(args.slice(1), client));
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 = 10;
10
+ export const SQLITE_SCHEMA_VERSION = 11;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
  /** Bounded forum persistence behind the Discourse mutation authority. */
13
13
  export const DISCOURSE_QUERY_MAX_LIMIT = 100;
package/src/db.ts CHANGED
@@ -250,6 +250,27 @@ CREATE TABLE IF NOT EXISTS artifact_scopes (
250
250
  assigned_at TEXT NOT NULL
251
251
  );
252
252
  CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
253
+ CREATE TABLE IF NOT EXISTS log_sources (
254
+ id TEXT PRIMARY KEY,
255
+ label TEXT NOT NULL,
256
+ project_root TEXT,
257
+ created_at TEXT NOT NULL
258
+ );
259
+ CREATE TABLE IF NOT EXISTS log_entries (
260
+ id TEXT PRIMARY KEY,
261
+ source_id TEXT NOT NULL REFERENCES log_sources(id),
262
+ occurred_at TEXT NOT NULL,
263
+ level TEXT NOT NULL CHECK (level IN ('debug', 'info', 'warning', 'error')),
264
+ message TEXT NOT NULL,
265
+ truncated INTEGER NOT NULL DEFAULT 0,
266
+ fields_json TEXT NOT NULL DEFAULT '{}',
267
+ operation_id TEXT NOT NULL,
268
+ session_id TEXT,
269
+ UNIQUE (source_id, operation_id)
270
+ );
271
+ CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
272
+ CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
273
+ BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
253
274
  `;
254
275
 
255
276
  const SEED_SQL = `
@@ -327,6 +348,7 @@ export interface ModuleMigrationRow {
327
348
  const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; checksum: string }> = [
328
349
  { version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
329
350
  { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
351
+ { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
330
352
  ];
331
353
 
332
354
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -401,9 +423,19 @@ export function migrateDb(db: Db): MigrationResult {
401
423
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
402
424
  }
403
425
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
404
- if (from !== 1 && from !== 2 && from !== 3 && from !== 4 && from !== 5 && from !== 6 && from !== 7) throw new Error(`no explicit migration path from database schema ${from}`);
426
+ if (from < 1) throw new Error(`no explicit migration path from database schema ${from}`);
405
427
  const applied: string[] = [];
406
428
 
429
+ // Deliberately NOT a hand-enumerated allow-list of valid `from` values (e.g. "from !== 1 &&
430
+ // ... && from !== 7"): a real, latent bug was found here while adding the v10->v11 step --
431
+ // that enumeration was never extended when the v8->v9 and v9->v10 steps were added, so
432
+ // migrating any already-deployed database sitting at schema 8, 9, or 10 (including the real
433
+ // production database at the time this was found) would have thrown "no explicit migration
434
+ // path" before ever reaching the migration chain below. Checked dynamically after the chain
435
+ // runs instead: if schemaVersion(db) hasn't reached SQLITE_SCHEMA_VERSION once every
436
+ // `schemaVersion(db) === N` step below has had its chance to fire, `from` was never a valid
437
+ // starting point (a genuine gap in the chain) -- structurally cannot drift out of sync the
438
+ // way a separate, parallel enumeration did.
407
439
  inTransaction(db, () => {
408
440
  if (schemaVersion(db) === 1) {
409
441
  db.exec(`
@@ -616,6 +648,34 @@ export function migrateDb(db: Db): MigrationResult {
616
648
  `);
617
649
  applied.push("docs-rules-skills-project-scope");
618
650
  }
651
+ if (schemaVersion(db) === 10) {
652
+ db.exec(`
653
+ CREATE TABLE IF NOT EXISTS log_sources (
654
+ id TEXT PRIMARY KEY,
655
+ label TEXT NOT NULL,
656
+ project_root TEXT,
657
+ created_at TEXT NOT NULL
658
+ );
659
+ CREATE TABLE IF NOT EXISTS log_entries (
660
+ id TEXT PRIMARY KEY,
661
+ source_id TEXT NOT NULL REFERENCES log_sources(id),
662
+ occurred_at TEXT NOT NULL,
663
+ level TEXT NOT NULL CHECK (level IN ('debug', 'info', 'warning', 'error')),
664
+ message TEXT NOT NULL,
665
+ truncated INTEGER NOT NULL DEFAULT 0,
666
+ fields_json TEXT NOT NULL DEFAULT '{}',
667
+ operation_id TEXT NOT NULL,
668
+ session_id TEXT,
669
+ UNIQUE (source_id, operation_id)
670
+ );
671
+ CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
672
+ CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
673
+ BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
674
+ PRAGMA user_version = 11;
675
+ `);
676
+ applied.push("log-domain");
677
+ }
678
+ if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
619
679
  });
620
680
  if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
621
681
  return { from, to: schemaVersion(db), applied };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * domain/log-entry.ts — the `log` domain: structured, timestamped event records from any
3
+ * source (an external adapter, a live session/context-window snapshot, later maybe
4
+ * Papyrus's own operations), captured for both post-mortem review and live tailing.
5
+ *
6
+ * Deliberately NOT an Artifact kind (see the "match durable output to the right artifact
7
+ * kind" Rule): a log entry has no lifecycle/status, is not individually curated, and the
8
+ * corpus is naturally unbounded/continuously growing -- the opposite of what belongs in the
9
+ * bounded, curated Artifact graph. Retention (LOG_RETENTION_MAX_ENTRIES_PER_SOURCE) is a
10
+ * first-class concern here in a way it deliberately is NOT for the permanent
11
+ * artifact_events/task_events audit trails: logs are meant to be rotated, not kept forever.
12
+ *
13
+ * Mirrors ConversationJournal's own discipline (idempotency via a caller-constructed
14
+ * composite operationId, explicit non-silent truncation) since both are append-only,
15
+ * externally-sourced record streams -- but logs have no reply structure and do carry a
16
+ * retention policy, which a durable conversation record deliberately does not.
17
+ */
18
+
19
+ export const LOG_LEVELS = ["debug", "info", "warning", "error"] as const;
20
+ export type LogLevel = typeof LOG_LEVELS[number];
21
+
22
+ export const LOG_SOURCE_ID_MAX_LENGTH = 256;
23
+ export const LOG_MESSAGE_MAX_CHARACTERS = 4000;
24
+ /** Bound on the serialized JSON size of an entry's structured fields. */
25
+ export const LOG_FIELDS_MAX_CHARACTERS = 8000;
26
+ export const LOG_QUERY_MAX_ENTRIES = 500;
27
+ /** Oldest entries beyond this count (per source) are trimmed on every append -- see the module comment on why logs are retained, not kept forever. */
28
+ export const LOG_RETENTION_MAX_ENTRIES_PER_SOURCE = 5000;
29
+
30
+ export type JsonPrimitive = string | number | boolean | null;
31
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
32
+
33
+ export interface LogSource {
34
+ readonly id: string;
35
+ readonly label: string;
36
+ readonly projectRoot: string | null;
37
+ readonly createdAt: string;
38
+ }
39
+
40
+ export interface LogEntry {
41
+ readonly id: string;
42
+ readonly sourceId: string;
43
+ readonly occurredAt: string;
44
+ readonly level: LogLevel;
45
+ readonly message: string;
46
+ /** True when message was cut to LOG_MESSAGE_MAX_CHARACTERS -- never silently. */
47
+ readonly truncated: boolean;
48
+ readonly fields: JsonValue;
49
+ /**
50
+ * Idempotency key. Must be a composite the caller constructs (e.g. `${sessionId}:${turn}`),
51
+ * never a bare upstream id alone -- a source's own local ids are commonly unique only
52
+ * within one recording run, not globally, matching ConversationJournal's identical
53
+ * operationId discipline and the concrete case it generalizes from (Pi's /tree lessons).
54
+ */
55
+ readonly operationId: string;
56
+ readonly sessionId?: string;
57
+ }
58
+
59
+ export interface AppendLogEntryCommand {
60
+ readonly sourceId: string;
61
+ /** Used only the first time this sourceId is seen; ignored on every later append to the same source. */
62
+ readonly sourceLabel?: string;
63
+ readonly projectRoot?: string | null;
64
+ readonly level: LogLevel;
65
+ readonly message: string;
66
+ readonly fields?: JsonValue;
67
+ readonly operationId: string;
68
+ readonly sessionId?: string;
69
+ /** Caller-supplied for post-mortem/backfilled entries with a real historical timestamp; defaults to now for live entries. */
70
+ readonly occurredAt?: string;
71
+ }
72
+
73
+ export interface AppendLogEntryResult {
74
+ readonly entry: LogEntry;
75
+ /** True when this exact operationId was already logged and this call was a safe no-op replay. */
76
+ readonly replayed: boolean;
77
+ }
78
+
79
+ export interface LogQuery {
80
+ readonly sourceId: string;
81
+ /** ISO timestamp, exclusive lower bound -- the live-tail/polling cursor. */
82
+ readonly since?: string;
83
+ /** A floor, not an exact match: "warning" returns warning and error, matching conventional log-level filters. */
84
+ readonly level?: LogLevel;
85
+ readonly limit?: number;
86
+ }
87
+
88
+ export interface LogEntryPage {
89
+ readonly entries: readonly LogEntry[];
90
+ /** True when more entries exist beyond this page -- never silently drop the remainder without saying so. */
91
+ readonly truncated: boolean;
92
+ }
93
+
94
+ function requireBounded(value: string, label: string, maxLength: number): string {
95
+ if (value.length === 0) throw new Error(`${label} is required`);
96
+ if (value.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters`);
97
+ return value;
98
+ }
99
+
100
+ export function validateAppendLogEntryCommand(command: AppendLogEntryCommand): void {
101
+ requireBounded(command.sourceId, "sourceId", LOG_SOURCE_ID_MAX_LENGTH);
102
+ requireBounded(command.operationId, "operationId", LOG_SOURCE_ID_MAX_LENGTH * 2);
103
+ if (command.message.length === 0) throw new Error("message is required");
104
+ if (!(LOG_LEVELS as readonly string[]).includes(command.level)) throw new Error(`level must be one of ${LOG_LEVELS.join(", ")}`);
105
+ const fieldsSize = command.fields === undefined ? 0 : JSON.stringify(command.fields).length;
106
+ if (fieldsSize > LOG_FIELDS_MAX_CHARACTERS) throw new Error(`fields exceeds ${LOG_FIELDS_MAX_CHARACTERS} characters when serialized`);
107
+ }
108
+
109
+ /** Applies the explicit truncation bound. Never silently drops the truncation fact -- callers must surface `truncated`. */
110
+ export function boundMessage(message: string): { message: string; truncated: boolean } {
111
+ if (message.length <= LOG_MESSAGE_MAX_CHARACTERS) return { message, truncated: false };
112
+ return { message: message.slice(0, LOG_MESSAGE_MAX_CHARACTERS), truncated: true };
113
+ }
114
+
115
+ const LEVEL_SEVERITY: Record<LogLevel, number> = { debug: 0, info: 1, warning: 2, error: 3 };
116
+
117
+ /** True when `level` meets or exceeds `minimum` -- LogQuery.level is a floor, not an exact match. */
118
+ export function meetsLevel(level: LogLevel, minimum: LogLevel): boolean {
119
+ return LEVEL_SEVERITY[level] >= LEVEL_SEVERITY[minimum];
120
+ }
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ boundMessage,
4
+ LOG_QUERY_MAX_ENTRIES,
5
+ LOG_RETENTION_MAX_ENTRIES_PER_SOURCE,
6
+ meetsLevel,
7
+ validateAppendLogEntryCommand,
8
+ type AppendLogEntryCommand,
9
+ type AppendLogEntryResult,
10
+ type LogEntryPage,
11
+ type LogQuery,
12
+ } from "./domain/log-entry.ts";
13
+ import type { LogStore } from "./ports/log-store.ts";
14
+
15
+ export class Logs {
16
+ constructor(private readonly store: LogStore) {}
17
+
18
+ append(command: AppendLogEntryCommand): AppendLogEntryResult {
19
+ validateAppendLogEntryCommand(command);
20
+ this.store.ensureSource(command.sourceId, command.sourceLabel ?? command.sourceId, command.projectRoot ?? null);
21
+ const existing = this.store.findEntryByOperationId(command.sourceId, command.operationId);
22
+ if (existing) return { entry: existing, replayed: true };
23
+
24
+ const { message, truncated } = boundMessage(command.message);
25
+ const entry = {
26
+ id: randomUUID(),
27
+ sourceId: command.sourceId,
28
+ occurredAt: command.occurredAt ?? new Date().toISOString(),
29
+ level: command.level,
30
+ message,
31
+ truncated,
32
+ fields: command.fields ?? {},
33
+ operationId: command.operationId,
34
+ sessionId: command.sessionId,
35
+ };
36
+ this.store.insertEntry(entry);
37
+ this.store.trimSource(command.sourceId, LOG_RETENTION_MAX_ENTRIES_PER_SOURCE);
38
+ return { entry, replayed: false };
39
+ }
40
+
41
+ /**
42
+ * `since` given: this is a live-tail/polling cursor -- return the OLDEST entries in the
43
+ * window so a caller can advance its cursor without skipping any, at the cost of not yet
44
+ * seeing the very latest (call again with a later `since` to keep catching up).
45
+ * `since` omitted: this is a post-mortem browse -- return the MOST RECENT entries,
46
+ * matching `tail -n`'s familiar behavior.
47
+ */
48
+ query(query: LogQuery): LogEntryPage {
49
+ const limit = Math.min(query.limit ?? LOG_QUERY_MAX_ENTRIES, LOG_QUERY_MAX_ENTRIES);
50
+ const matching = this.store.entriesForSource(query.sourceId)
51
+ .filter((entry) => query.since === undefined || entry.occurredAt > query.since)
52
+ .filter((entry) => query.level === undefined || meetsLevel(entry.level, query.level));
53
+
54
+ if (matching.length <= limit) return { entries: matching, truncated: false };
55
+ const windowed = query.since !== undefined ? matching.slice(0, limit) : matching.slice(matching.length - limit);
56
+ return { entries: windowed, truncated: true };
57
+ }
58
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * modules/logs.ts — the `log` domain as a registered Papyrus-native module.
3
+ *
4
+ * Deliberately self-contained: does not import artifact/task/rule/skill infrastructure --
5
+ * logs never touch the Artifact graph directly (see src/domain/log-entry.ts's own module
6
+ * comment on why `log` is not an Artifact kind).
7
+ */
8
+ import type { JsonValue, LogLevel } from "../domain/log-entry.ts";
9
+ import type { OperationDefinition } from "../module-registry.ts";
10
+ import type { Logs } from "../log-service.ts";
11
+
12
+ const MODULE_ID = "logs";
13
+
14
+ type OperationInput = Record<string, unknown>;
15
+
16
+ function string(input: OperationInput, key: string): string {
17
+ const value = input[key];
18
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
19
+ return value;
20
+ }
21
+
22
+ function optionalString(input: OperationInput, key: string): string | undefined {
23
+ const value = input[key];
24
+ if (value === undefined) return undefined;
25
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
26
+ return value;
27
+ }
28
+
29
+ function optionalNumber(input: OperationInput, key: string): number | undefined {
30
+ const value = input[key];
31
+ if (value === undefined) return undefined;
32
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
33
+ return value;
34
+ }
35
+
36
+ function isJsonValue(value: unknown): value is JsonValue {
37
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"
38
+ || Array.isArray(value) || (typeof value === "object");
39
+ }
40
+
41
+ function optionalFields(input: OperationInput, key: string): JsonValue | undefined {
42
+ const value = input[key];
43
+ if (value === undefined) return undefined;
44
+ if (!isJsonValue(value)) throw new Error(`${key} must be JSON-serializable`);
45
+ return value;
46
+ }
47
+
48
+ /** 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. */
49
+ export const LOGS_OPERATION_NAMES = ["logs.append", "logs.query"] as const;
50
+
51
+ /** Registers every logs.* operation against one Logs instance. */
52
+ export function logsOperations(logs: Logs): OperationDefinition[] {
53
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
54
+ name, moduleId: MODULE_ID, execute,
55
+ });
56
+ return [
57
+ define("logs.append", (input: OperationInput) => logs.append({
58
+ sourceId: string(input, "source_id"),
59
+ sourceLabel: optionalString(input, "source_label"),
60
+ projectRoot: optionalString(input, "project_root") ?? null,
61
+ level: string(input, "level") as LogLevel,
62
+ message: string(input, "message"),
63
+ fields: optionalFields(input, "fields"),
64
+ operationId: string(input, "operation_id"),
65
+ sessionId: optionalString(input, "session_id"),
66
+ occurredAt: optionalString(input, "occurred_at"),
67
+ })),
68
+ define("logs.query", (input: OperationInput) => logs.query({
69
+ sourceId: string(input, "source_id"),
70
+ since: optionalString(input, "since"),
71
+ level: optionalString(input, "level") as LogLevel | undefined,
72
+ limit: optionalNumber(input, "limit"),
73
+ })),
74
+ ];
75
+ }
@@ -0,0 +1,17 @@
1
+ import type { LogEntry, LogSource } from "../domain/log-entry.ts";
2
+
3
+ /**
4
+ * Persistence port for the `log` domain. Deliberately minimal, matching
5
+ * ConversationJournalStore's own split: this is dumb storage (idempotency-key lookup,
6
+ * insert, bounded-at-the-service-layer reads) plus one operation the store must own because
7
+ * only it knows real row counts -- retention trimming.
8
+ */
9
+ export interface LogStore {
10
+ ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource;
11
+ findEntryByOperationId(sourceId: string, operationId: string): LogEntry | undefined;
12
+ insertEntry(entry: LogEntry): void;
13
+ /** All entries for one source, chronological (oldest first), unbounded at the store layer -- the service applies query bounds/filters. */
14
+ entriesForSource(sourceId: string): readonly LogEntry[];
15
+ /** Deletes the oldest entries for a source beyond `maxEntries`, returning how many were removed -- retention enforcement, not a general delete capability. */
16
+ trimSource(sourceId: string, maxEntries: number): number;
17
+ }
package/src/service.ts CHANGED
@@ -24,9 +24,12 @@ import {
24
24
  listInjectableRules,
25
25
  } from "./domain-services.ts";
26
26
  import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
27
+ import { Logs } from "./log-service.ts";
28
+ import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
27
29
  import { OperationRegistry } from "./module-registry.ts";
28
30
  import { docsOperations, DOCS_OPERATION_NAMES } from "./modules/docs.ts";
29
31
  import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./modules/graph-projection.ts";
32
+ import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
30
33
  import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
31
34
  import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
32
35
  import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
@@ -65,6 +68,7 @@ export const EXPECTED_OPERATION_NAMES = [
65
68
  ...RULES_OPERATION_NAMES,
66
69
  ...SKILLS_OPERATION_NAMES,
67
70
  ...GRAPH_PROJECTION_OPERATION_NAMES,
71
+ ...LOGS_OPERATION_NAMES,
68
72
  ] as const;
69
73
 
70
74
  export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
@@ -371,6 +375,8 @@ function handlers(
371
375
  },
372
376
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
373
377
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
378
+ "logs.append": forwardToModule("logs.append"),
379
+ "logs.query": forwardToModule("logs.query"),
374
380
  };
375
381
  }
376
382
 
@@ -386,9 +392,11 @@ export function createPapyrusService(path: string): PapyrusService {
386
392
  const discourse = new SQLiteDiscourseStore(db, artifacts);
387
393
  const projections = new SQLiteGraphProjectionStore(db);
388
394
  const artifactScopes = new SQLiteArtifactScopeStore(db);
395
+ const logs = new Logs(new SQLiteLogStore(db));
389
396
  const authority = createAuthorityRegistry();
390
397
  const moduleRegistry = new OperationRegistry();
391
398
  moduleRegistry.registerAll(notesOperations(notes));
399
+ moduleRegistry.registerAll(logsOperations(logs));
392
400
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts));
393
401
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
394
402
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));