@danypops/papyrus 0.17.0 → 0.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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"],
@@ -31,6 +31,10 @@ export class SQLiteTaskFocusStore implements TaskFocusStore {
31
31
  });
32
32
  }
33
33
 
34
+ reapStale(olderThanIso: string): number {
35
+ return this.db.prepare("DELETE FROM task_focus WHERE updated_at < ?").run(olderThanIso).changes;
36
+ }
37
+
34
38
  private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason: string | undefined, scope: string | undefined): TaskFocusState {
35
39
  const current = this.get(scope);
36
40
  if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
package/src/cli.ts CHANGED
@@ -122,6 +122,7 @@ const USAGE = `Usage:
122
122
  papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
123
123
  papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
124
124
  papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
125
+ papyrus tasks reap-stale-focus [--json]
125
126
  papyrus tasks history <id> [--json]
126
127
  papyrus tasks scope [project|all|graph <root-id>] [--json]
127
128
  papyrus tasks assign-project <id> [project-root] [--json]
@@ -1119,6 +1120,13 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1119
1120
  human = cleared.cleared ? "Task focus cleared." : "No focused task.";
1120
1121
  break;
1121
1122
  }
1123
+ case "reap-stale-focus": {
1124
+ if (id) throw new Error("tasks reap-stale-focus accepts no positional arguments");
1125
+ const reaped = await client.call<Record<string, unknown>, { removed: number }>("tasks.reap_stale_focus", {});
1126
+ result = reaped;
1127
+ human = `Reaped ${reaped.removed} stale Focus scope(s).`;
1128
+ break;
1129
+ }
1122
1130
  case "create": {
1123
1131
  if (id) throw new Error("tasks create accepts no positional arguments");
1124
1132
  if (!title) throw new Error("tasks create requires --title");
package/src/constants.ts CHANGED
@@ -146,6 +146,17 @@ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
146
146
  export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
147
147
  /** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
148
148
  export const TASK_FOCUS_MAX_SCOPES = 500;
149
+ /**
150
+ * A Task Focus row not touched by any Focus-mutating operation (focus/pause/unpause) in this
151
+ * long is eligible for time-based reaping (see Tasks.reapStaleFocus), independent of the
152
+ * TASK_FOCUS_MAX_SCOPES hard cap above -- see clean-up-stale-per-session-task-focus-rows-
153
+ * on-real-session-l-9i7s. Deliberately NOT driven by Pi's session_start/session_shutdown
154
+ * hooks: a "resume" reuses the exact same session_id as a prior process incarnation, so
155
+ * neither hook reliably signals "this session is gone forever" -- only real elapsed time
156
+ * without any Focus activity does. 30 days is long enough that a genuine multi-week pause-
157
+ * and-resume workflow survives; short enough to actually bound long-run accumulation.
158
+ */
159
+ export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
149
160
  /** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
150
161
  export const SESSION_IDENTITY_MAX_ROWS = 2_000;
151
162
  /** Persisted project and focused-graph Task view bounds. */
package/src/daemon.ts CHANGED
@@ -25,12 +25,22 @@ export function serveMain(): void {
25
25
  const optimizeTimer = setInterval(() => {
26
26
  try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
27
27
  }, DB_OPTIMIZE_INTERVAL_MS);
28
+ // Daily cadence (reusing DB_OPTIMIZE_INTERVAL_MS) is plenty against a 30-day staleness
29
+ // threshold (TASK_FOCUS_STALE_AFTER_MS) -- see clean-up-stale-per-session-task-focus-rows-
30
+ // on-real-session-l-9i7s.
31
+ const reapFocusTimer = setInterval(() => {
32
+ try {
33
+ const removed = service.reapStaleFocus();
34
+ if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
35
+ } catch (error) { logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) }); }
36
+ }, DB_OPTIMIZE_INTERVAL_MS);
28
37
  let stopping = false;
29
38
  const shutdown = () => {
30
39
  if (stopping) return;
31
40
  stopping = true;
32
41
  clearInterval(checkpointTimer);
33
42
  clearInterval(optimizeTimer);
43
+ clearInterval(reapFocusTimer);
34
44
  clearDaemonPort(stateDir);
35
45
  service.close();
36
46
  void server.stop(true).finally(() => process.exit(0));
package/src/db.ts CHANGED
@@ -20,7 +20,8 @@ const DatabaseCtor = (
20
20
  ) as new (path: string, opts?: { create?: boolean }) => Db;
21
21
 
22
22
  export interface DbStatement {
23
- run(...params: unknown[]): { lastInsertRowid: number | bigint };
23
+ /** changes: number of rows the statement affected. Both bun:sqlite and node:sqlite's real run() return this at runtime; declared here so callers (e.g. reapStale) can rely on it without an unsafe cast. */
24
+ run(...params: unknown[]): { lastInsertRowid: number | bigint; changes: number };
24
25
  get(...params: unknown[]): unknown;
25
26
  all(...params: unknown[]): unknown[];
26
27
  }
@@ -90,7 +90,7 @@ export const TASKS_OPERATION_NAMES = [
90
90
  "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus",
91
91
  "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
92
92
  "tasks.run_gates", "tasks.set_checklist", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
93
- "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
93
+ "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
94
94
  ] as const;
95
95
 
96
96
  export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
@@ -151,6 +151,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
151
151
  define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input)); }),
152
152
  define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input)); }),
153
153
  define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }),
154
+ define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })),
154
155
  define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
155
156
  define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
156
157
  define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
@@ -30,6 +30,8 @@ export interface TaskFocusStore {
30
30
  clear(taskId?: string, scope?: string): void;
31
31
  /** Clears this task's Focus in every scope (session), not just one — for lifecycle events (e.g. cancel) that are not scoped to a single caller. */
32
32
  clearEverywhere(taskId: string): void;
33
+ /** Deletes every Focus row whose updatedAt is strictly before olderThanIso (see TASK_FOCUS_STALE_AFTER_MS). Returns how many rows were removed. */
34
+ reapStale(olderThanIso: string): number;
33
35
  }
34
36
 
35
37
  export class InMemoryTaskFocusStore implements TaskFocusStore {
@@ -74,6 +76,14 @@ export class InMemoryTaskFocusStore implements TaskFocusStore {
74
76
  }
75
77
  }
76
78
 
79
+ reapStale(olderThanIso: string): number {
80
+ let removed = 0;
81
+ for (const [key, focus] of this.state) {
82
+ if (focus.updatedAt < olderThanIso) { this.state.delete(key); removed++; }
83
+ }
84
+ return removed;
85
+ }
86
+
77
87
  private evictOldest(): void {
78
88
  let oldestKey: string | undefined;
79
89
  let oldestAt: string | undefined;
package/src/service.ts CHANGED
@@ -171,6 +171,8 @@ export interface PapyrusService {
171
171
  execute(operation: string, input?: OperationInput): Promise<unknown>;
172
172
  checkpoint(): void;
173
173
  optimize(): void;
174
+ /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
175
+ reapStaleFocus(): number;
174
176
  close(): void;
175
177
  }
176
178
 
@@ -318,6 +320,7 @@ function handlers(
318
320
  "tasks.undepend": forwardToModule("tasks.undepend"),
319
321
  "tasks.contain": forwardToModule("tasks.contain"),
320
322
  "tasks.uncontain": forwardToModule("tasks.uncontain"),
323
+ "tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
321
324
  "docs.create": forwardToModule("docs.create"),
322
325
  "docs.list": forwardToModule("docs.list"),
323
326
  "docs.show": forwardToModule("docs.show"),
@@ -417,6 +420,7 @@ export function createPapyrusService(path: string): PapyrusService {
417
420
  },
418
421
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
419
422
  optimize: () => { db.exec("PRAGMA optimize"); },
423
+ reapStaleFocus: () => tasks.reapStaleFocus(),
420
424
  close: () => {
421
425
  db.exec("PRAGMA optimize");
422
426
  db.close();
@@ -5,6 +5,7 @@ import {
5
5
  TASK_EXECUTION_MAX_NODES,
6
6
  TASK_LABEL_MAX_COUNT,
7
7
  TASK_LABEL_MAX_LENGTH,
8
+ TASK_FOCUS_STALE_AFTER_MS,
8
9
  TASK_SCOPE_MAX_TASKS,
9
10
  TASK_TITLE_MAX_LENGTH,
10
11
  } from "./constants.ts";
@@ -383,6 +384,19 @@ export class Tasks {
383
384
  });
384
385
  }
385
386
 
387
+ /**
388
+ * Time-based reclamation of Focus scopes nobody has touched in TASK_FOCUS_STALE_AFTER_MS,
389
+ * independent of and in addition to the TASK_FOCUS_MAX_SCOPES hard cap -- see
390
+ * clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
391
+ * comment on why this is deliberately not driven by session_start/session_shutdown.
392
+ * No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
393
+ * mutation, and there is no longer a specific session/actor to attribute it to.
394
+ */
395
+ reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
396
+ const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
397
+ return this.focusStore.reapStale(cutoff);
398
+ }
399
+
386
400
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
387
401
  return this.events.atomic(() => {
388
402
  const task = this.require(id);