@danypops/papyrus 0.13.6 → 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.
- package/extension/src/index.ts +36 -2
- package/package.json +1 -1
- package/src/adapters/sqlite-log-store.ts +88 -0
- package/src/cli.ts +72 -0
- package/src/constants.ts +1 -1
- package/src/db.ts +61 -1
- package/src/domain/log-entry.ts +120 -0
- package/src/log-service.ts +58 -0
- package/src/modules/logs.ts +75 -0
- package/src/ports/log-store.ts +17 -0
- package/src/service.ts +8 -0
package/extension/src/index.ts
CHANGED
|
@@ -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) => {
|
|
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
|
@@ -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
|
+
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
|
|
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));
|