@danypops/papyrus 0.13.6 → 0.15.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.
@@ -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({
@@ -501,7 +532,10 @@ export default async function (pi: ExtensionAPI) {
501
532
  }
502
533
  });
503
534
  pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
504
- 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
+ });
505
539
 
506
540
  // ── "Are we there yet?" — inject active tasks into every turn ──────
507
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.6",
3
+ "version": "0.15.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
+ }
@@ -25,7 +25,7 @@ import type { ArtifactStore } from "./ports/artifact-store.ts";
25
25
  export type ArtifactAction = "create" | "link" | "status";
26
26
 
27
27
  export interface AuthorityClaim {
28
- /** Module id that owns this kind/subtype/relation, e.g. "discourse", "notes", "tasks". */
28
+ /** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */
29
29
  readonly owner: string;
30
30
  /** kind may be undefined at a call site that has not yet resolved an artifact's effective kind (e.g. pre-template-resolution). */
31
31
  matchesArtifact(kind: string | undefined, subtype: string | undefined): boolean;
package/src/cli.ts CHANGED
@@ -71,7 +71,6 @@ const USAGE = `Usage:
71
71
  papyrus migrate-ids mirror [--db <path>] --out <mirror-path> [--json]
72
72
  papyrus migrate-ids validate --mirror <mirror-path> [--idmap <path>] [--json]
73
73
  papyrus migrate-ids promote --mirror <mirror-path> [--db <path>] [--idmap <path>] [--force] [--json]
74
- papyrus discourse store <action> --store-id <id> [--input-json <json>] [--json]
75
74
  papyrus graph link <from> <relation> <to> [--json]
76
75
  papyrus graph unlink <from> <relation> <to> [--json]
77
76
  papyrus graph tree <id> [--depth <n>] [--max-nodes <n>] [--json]
@@ -112,6 +111,8 @@ const USAGE = `Usage:
112
111
  papyrus notes consume <id> [--reason <reason>] [--json]
113
112
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
114
113
  papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
114
+ 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]
115
+ papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
115
116
  papyrus tasks plan [--session-id <id>] [--json]
116
117
  papyrus tasks graph [--session-id <id>] [--json]
117
118
  papyrus tasks active [--session-id <id>] [--json]
@@ -306,36 +307,6 @@ export function runIdMigrationCli(args: string[]): string {
306
307
  throw new Error("migrate-ids requires one of: mirror, validate, promote");
307
308
  }
308
309
 
309
- export async function runDiscourseCli(args: string[], client: TaskCliClient): Promise<string> {
310
- const json = args.includes("--json");
311
- const positional: string[] = [];
312
- let storeId: string | undefined;
313
- let operationInput: Record<string, unknown> = {};
314
- for (let index = 0; index < args.length; index++) {
315
- const argument = args[index]!;
316
- if (argument === "--json") continue;
317
- if (argument === "--store-id" || argument === "--input-json") {
318
- const value = args[++index];
319
- if (!value) throw new Error(`${argument} requires a value`);
320
- if (argument === "--store-id") storeId = value;
321
- else {
322
- const parsed = JSON.parse(value) as unknown;
323
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--input-json must be a JSON object");
324
- operationInput = parsed as Record<string, unknown>;
325
- }
326
- continue;
327
- }
328
- if (argument.startsWith("--")) throw new Error(`unknown discourse option ${argument}`);
329
- positional.push(argument);
330
- }
331
- if (positional.length !== 2 || positional[0] !== "store") throw new Error("discourse requires `store <action>`");
332
- if (!storeId) throw new Error("discourse store requires --store-id");
333
- const result = await client.call<Record<string, unknown>, unknown>("discourse.store", {
334
- action: positional[1], store_id: storeId, ...operationInput,
335
- });
336
- return json ? JSON.stringify(result) : `Discourse store ${positional[1]} completed.`;
337
- }
338
-
339
310
  export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
340
311
  const json = args.includes("--json");
341
312
  const positional: string[] = [];
@@ -873,6 +844,71 @@ export async function runGraphProjectionCli(args: string[], client: TaskCliClien
873
844
  throw new Error("graph-projection action must be apply or checkpoint");
874
845
  }
875
846
 
847
+ export async function runLogCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
848
+ const json = args.includes("--json");
849
+ const positional: string[] = [];
850
+ let source: string | undefined;
851
+ let sourceLabel: string | undefined;
852
+ let level: string | undefined;
853
+ let message: string | undefined;
854
+ let operationId: string | undefined;
855
+ let sessionId: string | undefined;
856
+ let occurredAt: string | undefined;
857
+ let since: string | undefined;
858
+ let limit: number | undefined;
859
+ let fields: Record<string, unknown> | undefined;
860
+ let global = false;
861
+ for (let index = 0; index < args.length; index++) {
862
+ const argument = args[index]!;
863
+ if (argument === "--json") continue;
864
+ if (argument === "--global") { global = true; continue; }
865
+ if (argument === "--fields-json") { fields = parseJsonObjectFlag(args[++index], "--fields-json"); continue; }
866
+ if (["--source", "--source-label", "--level", "--message", "--operation-id", "--session-id", "--occurred-at", "--since", "--limit"].includes(argument)) {
867
+ const value = args[++index];
868
+ if (value === undefined) throw new Error(`${argument} requires a value`);
869
+ if (argument === "--source") source = value;
870
+ else if (argument === "--source-label") sourceLabel = value;
871
+ else if (argument === "--level") level = value;
872
+ else if (argument === "--message") message = value;
873
+ else if (argument === "--operation-id") operationId = value;
874
+ else if (argument === "--session-id") sessionId = value;
875
+ else if (argument === "--occurred-at") occurredAt = value;
876
+ else if (argument === "--since") since = value;
877
+ else {
878
+ limit = Number(value);
879
+ if (!Number.isInteger(limit)) throw new Error("--limit requires an integer");
880
+ }
881
+ continue;
882
+ }
883
+ if (argument.startsWith("--")) throw new Error(`unknown log option ${argument}`);
884
+ positional.push(argument);
885
+ }
886
+ const [action] = positional;
887
+ if (action === "append") {
888
+ if (!source) throw new Error("log append requires --source");
889
+ if (!level) throw new Error("log append requires --level");
890
+ if (!message) throw new Error("log append requires --message");
891
+ if (!operationId) throw new Error("log append requires --operation-id");
892
+ const result = await client.call("logs.append", {
893
+ source_id: source, ...(sourceLabel ? { source_label: sourceLabel } : {}),
894
+ ...(global ? {} : { project_root: projectRoot }),
895
+ level, message, operation_id: operationId,
896
+ ...(fields ? { fields } : {}), ...(sessionId ? { session_id: sessionId } : {}), ...(occurredAt ? { occurred_at: occurredAt } : {}),
897
+ });
898
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
899
+ }
900
+ if (action === "query") {
901
+ if (!source) throw new Error("log query requires --source");
902
+ const result = await client.call<Record<string, unknown>, { entries: unknown[]; truncated: boolean }>("logs.query", {
903
+ source_id: source, ...(since ? { since } : {}), ...(level ? { level } : {}), ...(limit === undefined ? {} : { limit }),
904
+ });
905
+ if (json) return JSON.stringify(result);
906
+ const lines = result.entries.map((entry) => JSON.stringify(entry));
907
+ return [...lines, result.truncated ? `(truncated -- more entries exist beyond this page)` : `(${lines.length} entries)`].join("\n");
908
+ }
909
+ throw new Error("log action must be append or query");
910
+ }
911
+
876
912
  export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
877
913
  const json = args.includes("--json");
878
914
  const positional: string[] = [];
@@ -1257,11 +1293,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1257
1293
  console.log(await runTaskCli(args.slice(1), client));
1258
1294
  return;
1259
1295
  }
1260
- if (command === "discourse") {
1261
- const client = await connectPapyrusClient();
1262
- console.log(await runDiscourseCli(args.slice(1), client));
1263
- return;
1264
- }
1265
1296
  if (command === "skills") {
1266
1297
  const client = await connectPapyrusClient();
1267
1298
  console.log(await runSkillCli(args.slice(1), client));
@@ -1272,6 +1303,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1272
1303
  console.log(await runNoteCli(args.slice(1), client));
1273
1304
  return;
1274
1305
  }
1306
+ if (command === "log") {
1307
+ const client = await connectPapyrusClient();
1308
+ console.log(await runLogCli(args.slice(1), client));
1309
+ return;
1310
+ }
1275
1311
  if (command === "migrate") {
1276
1312
  const client = await connectPapyrusClient();
1277
1313
  console.log(await runMigrationCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,14 +7,9 @@ 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 = 12;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
- /** Bounded forum persistence behind the Discourse mutation authority. */
13
- export const DISCOURSE_QUERY_MAX_LIMIT = 100;
14
- export const DISCOURSE_CONTENT_MAX_BYTES = 65_536;
15
- export const DISCOURSE_EVENT_RETENTION_DEFAULT = 1_000;
16
- export const DISCOURSE_EVENT_RETENTION_MAX = 10_000;
17
- export const DISCOURSE_PARTICIPANT_MAX_COUNT = 100;
12
+
18
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
19
14
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
20
15
  export const GATE_COMMAND_TIMEOUT_MS = 30_000;
package/src/db.ts CHANGED
@@ -147,67 +147,6 @@ CREATE TABLE IF NOT EXISTS task_views (
147
147
  updated_at TEXT NOT NULL,
148
148
  CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
149
149
  );
150
- CREATE TABLE IF NOT EXISTS discourse_threads (
151
- store_id TEXT NOT NULL,
152
- forum_id TEXT NOT NULL,
153
- topic_id TEXT NOT NULL,
154
- thread_id TEXT NOT NULL,
155
- artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
156
- PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
157
- );
158
- CREATE TABLE IF NOT EXISTS discourse_posts (
159
- store_id TEXT NOT NULL,
160
- sequence INTEGER NOT NULL,
161
- id TEXT NOT NULL,
162
- artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
163
- operation_id TEXT NOT NULL,
164
- command_json TEXT NOT NULL,
165
- forum_id TEXT NOT NULL,
166
- topic_id TEXT NOT NULL,
167
- thread_id TEXT NOT NULL,
168
- author_id TEXT NOT NULL,
169
- content_json TEXT NOT NULL,
170
- timestamp INTEGER NOT NULL,
171
- correlation_id TEXT,
172
- causation_id TEXT,
173
- reply_to_post_id TEXT,
174
- references_json TEXT NOT NULL,
175
- question_type TEXT CHECK (question_type IN ('question', 'answer')),
176
- response_id TEXT,
177
- target_id TEXT,
178
- PRIMARY KEY (store_id, id),
179
- UNIQUE (store_id, operation_id),
180
- UNIQUE (store_id, sequence)
181
- );
182
- CREATE INDEX IF NOT EXISTS discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
183
- CREATE TABLE IF NOT EXISTS discourse_events (
184
- store_id TEXT NOT NULL,
185
- sequence INTEGER NOT NULL,
186
- event_json TEXT NOT NULL,
187
- PRIMARY KEY (store_id, sequence)
188
- );
189
- CREATE TABLE IF NOT EXISTS discourse_cursors (
190
- store_id TEXT NOT NULL,
191
- consumer_id TEXT NOT NULL,
192
- sequence INTEGER NOT NULL,
193
- PRIMARY KEY (store_id, consumer_id)
194
- );
195
- CREATE TABLE IF NOT EXISTS discourse_projection_cursors (
196
- store_id TEXT NOT NULL,
197
- projection_id TEXT NOT NULL,
198
- sequence INTEGER NOT NULL,
199
- PRIMARY KEY (store_id, projection_id)
200
- );
201
- CREATE TRIGGER IF NOT EXISTS discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
202
- WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
203
- BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
204
- CREATE TRIGGER IF NOT EXISTS discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
205
- WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
206
- BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
207
- CREATE TRIGGER IF NOT EXISTS discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
208
- WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
209
- OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
210
- BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
211
150
  CREATE TABLE IF NOT EXISTS artifact_events (
212
151
  id INTEGER PRIMARY KEY AUTOINCREMENT,
213
152
  artifact_id TEXT NOT NULL REFERENCES artifacts(id),
@@ -250,6 +189,27 @@ CREATE TABLE IF NOT EXISTS artifact_scopes (
250
189
  assigned_at TEXT NOT NULL
251
190
  );
252
191
  CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
192
+ CREATE TABLE IF NOT EXISTS log_sources (
193
+ id TEXT PRIMARY KEY,
194
+ label TEXT NOT NULL,
195
+ project_root TEXT,
196
+ created_at TEXT NOT NULL
197
+ );
198
+ CREATE TABLE IF NOT EXISTS log_entries (
199
+ id TEXT PRIMARY KEY,
200
+ source_id TEXT NOT NULL REFERENCES log_sources(id),
201
+ occurred_at TEXT NOT NULL,
202
+ level TEXT NOT NULL CHECK (level IN ('debug', 'info', 'warning', 'error')),
203
+ message TEXT NOT NULL,
204
+ truncated INTEGER NOT NULL DEFAULT 0,
205
+ fields_json TEXT NOT NULL DEFAULT '{}',
206
+ operation_id TEXT NOT NULL,
207
+ session_id TEXT,
208
+ UNIQUE (source_id, operation_id)
209
+ );
210
+ CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
211
+ CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
212
+ BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
253
213
  `;
254
214
 
255
215
  const SEED_SQL = `
@@ -282,8 +242,6 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
282
242
  INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
283
243
  INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
284
244
  INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
285
- INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
286
- INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
287
245
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
288
246
  `;
289
247
 
@@ -327,6 +285,8 @@ export interface ModuleMigrationRow {
327
285
  const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; checksum: string }> = [
328
286
  { version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
329
287
  { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
288
+ { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
289
+ { version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
330
290
  ];
331
291
 
332
292
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -401,9 +361,19 @@ export function migrateDb(db: Db): MigrationResult {
401
361
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
402
362
  }
403
363
  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}`);
364
+ if (from < 1) throw new Error(`no explicit migration path from database schema ${from}`);
405
365
  const applied: string[] = [];
406
366
 
367
+ // Deliberately NOT a hand-enumerated allow-list of valid `from` values (e.g. "from !== 1 &&
368
+ // ... && from !== 7"): a real, latent bug was found here while adding the v10->v11 step --
369
+ // that enumeration was never extended when the v8->v9 and v9->v10 steps were added, so
370
+ // migrating any already-deployed database sitting at schema 8, 9, or 10 (including the real
371
+ // production database at the time this was found) would have thrown "no explicit migration
372
+ // path" before ever reaching the migration chain below. Checked dynamically after the chain
373
+ // runs instead: if schemaVersion(db) hasn't reached SQLITE_SCHEMA_VERSION once every
374
+ // `schemaVersion(db) === N` step below has had its chance to fire, `from` was never a valid
375
+ // starting point (a genuine gap in the chain) -- structurally cannot drift out of sync the
376
+ // way a separate, parallel enumeration did.
407
377
  inTransaction(db, () => {
408
378
  if (schemaVersion(db) === 1) {
409
379
  db.exec(`
@@ -616,6 +586,58 @@ export function migrateDb(db: Db): MigrationResult {
616
586
  `);
617
587
  applied.push("docs-rules-skills-project-scope");
618
588
  }
589
+ if (schemaVersion(db) === 10) {
590
+ db.exec(`
591
+ CREATE TABLE IF NOT EXISTS log_sources (
592
+ id TEXT PRIMARY KEY,
593
+ label TEXT NOT NULL,
594
+ project_root TEXT,
595
+ created_at TEXT NOT NULL
596
+ );
597
+ CREATE TABLE IF NOT EXISTS log_entries (
598
+ id TEXT PRIMARY KEY,
599
+ source_id TEXT NOT NULL REFERENCES log_sources(id),
600
+ occurred_at TEXT NOT NULL,
601
+ level TEXT NOT NULL CHECK (level IN ('debug', 'info', 'warning', 'error')),
602
+ message TEXT NOT NULL,
603
+ truncated INTEGER NOT NULL DEFAULT 0,
604
+ fields_json TEXT NOT NULL DEFAULT '{}',
605
+ operation_id TEXT NOT NULL,
606
+ session_id TEXT,
607
+ UNIQUE (source_id, operation_id)
608
+ );
609
+ CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
610
+ CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
611
+ BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
612
+ PRAGMA user_version = 11;
613
+ `);
614
+ applied.push("log-domain");
615
+ }
616
+ if (schemaVersion(db) === 11) {
617
+ // Removes Discourse's Papyrus-embedded storage entirely: confirmed zero rows in every
618
+ // discourse_* table and zero Docs carrying the reserved context-thread/context-message
619
+ // subtypes in the real production database before this was written -- Discourse's real
620
+ // home is now the standalone @danypops/discourse package plus host adapters, and
621
+ // Papyrus's own copy never had a single real caller since it was built. IF EXISTS
622
+ // throughout: a database that never actually reached the v5->v6 discourse-context-mesh
623
+ // step in the first place (e.g. a test fixture that starts partway through the chain)
624
+ // must not fail here just because there was nothing to remove.
625
+ db.exec(`
626
+ DROP TRIGGER IF EXISTS discourse_artifact_type_immutable;
627
+ DROP TRIGGER IF EXISTS discourse_posts_artifact_type;
628
+ DROP TRIGGER IF EXISTS discourse_threads_artifact_type;
629
+ DROP INDEX IF EXISTS discourse_posts_thread_idx;
630
+ DROP TABLE IF EXISTS discourse_projection_cursors;
631
+ DROP TABLE IF EXISTS discourse_cursors;
632
+ DROP TABLE IF EXISTS discourse_events;
633
+ DROP TABLE IF EXISTS discourse_posts;
634
+ DROP TABLE IF EXISTS discourse_threads;
635
+ DELETE FROM relation_names WHERE name IN ('reply_to', 'discusses');
636
+ PRAGMA user_version = 12;
637
+ `);
638
+ applied.push("remove-discourse");
639
+ }
640
+ if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
619
641
  });
620
642
  if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
621
643
  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
+ }