clawmem 0.7.2 → 0.8.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/src/store.ts CHANGED
@@ -496,16 +496,35 @@ function initializeDatabase(db: Database): void {
496
496
  injected_paths TEXT NOT NULL DEFAULT '[]',
497
497
  estimated_tokens INTEGER NOT NULL DEFAULT 0,
498
498
  was_referenced INTEGER NOT NULL DEFAULT 0,
499
- turn_index INTEGER NOT NULL DEFAULT 0
499
+ turn_index INTEGER NOT NULL DEFAULT 0,
500
+ query_text TEXT
500
501
  )
501
502
  `);
502
503
  db.exec(`CREATE INDEX IF NOT EXISTS idx_context_usage_session ON context_usage(session_id)`);
503
504
 
504
505
  // Migration: add turn_index to existing context_usage
505
- const cuCols = db.prepare("PRAGMA table_info(context_usage)").all() as { name: string }[];
506
+ let cuCols = db.prepare("PRAGMA table_info(context_usage)").all() as { name: string }[];
506
507
  if (!cuCols.some(c => c.name === "turn_index")) {
507
508
  try { db.exec(`ALTER TABLE context_usage ADD COLUMN turn_index INTEGER NOT NULL DEFAULT 0`); } catch { /* exists */ }
509
+ cuCols = db.prepare("PRAGMA table_info(context_usage)").all() as { name: string }[];
510
+ }
511
+
512
+ // v0.8.1 Ext 6b: add nullable query_text column to existing context_usage
513
+ // so multi-turn lookback can persist the raw prompt alongside turn_index.
514
+ // The column is nullable and defaults to NULL — pre-migration rows are
515
+ // treated as "no prior query" by buildMultiTurnSurfacingQuery, preserving
516
+ // the current-prompt-only fallback for any session that predates v0.8.1.
517
+ if (!cuCols.some(c => c.name === "query_text")) {
518
+ try { db.exec(`ALTER TABLE context_usage ADD COLUMN query_text TEXT`); } catch { /* exists */ }
508
519
  }
520
+ // Cache the column presence for insertUsageFn so it can build the INSERT
521
+ // statement without running PRAGMA table_info on every write path.
522
+ contextUsageHasQueryTextCache.set(
523
+ db,
524
+ db.prepare("PRAGMA table_info(context_usage)")
525
+ .all()
526
+ .some((c) => (c as { name: string }).name === "query_text"),
527
+ );
509
528
 
510
529
  // Hook prompt dedupe: suppress duplicate/heartbeat prompts to reduce GPU churn.
511
530
  db.exec(`
@@ -854,12 +873,53 @@ function initializeDatabase(db: Database): void {
854
873
  if (!mrColNames.has("contradict_confidence")) {
855
874
  try { db.exec(`ALTER TABLE memory_relations ADD COLUMN contradict_confidence REAL`); } catch { /* column exists */ }
856
875
  }
876
+
877
+ // v0.8.0 Ext 5: Heavy maintenance lane journal. Every scheduled attempt
878
+ // writes one row — including skips — so operators can reconstruct why a
879
+ // lane did or did not run on any tick.
880
+ db.exec(`
881
+ CREATE TABLE IF NOT EXISTS maintenance_runs (
882
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
883
+ lane TEXT NOT NULL,
884
+ phase TEXT NOT NULL,
885
+ status TEXT NOT NULL,
886
+ reason TEXT,
887
+ selected_count INTEGER NOT NULL DEFAULT 0,
888
+ processed_count INTEGER NOT NULL DEFAULT 0,
889
+ created_count INTEGER NOT NULL DEFAULT 0,
890
+ updated_count INTEGER NOT NULL DEFAULT 0,
891
+ rejected_count INTEGER NOT NULL DEFAULT 0,
892
+ null_call_count INTEGER NOT NULL DEFAULT 0,
893
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
894
+ finished_at TEXT,
895
+ metrics_json TEXT
896
+ )
897
+ `);
898
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_maintenance_runs_lane_started ON maintenance_runs(lane, started_at DESC)`);
899
+
900
+ // v0.8.0 Ext 5: DB-backed worker lease table for multi-process exclusivity
901
+ // on the heavy lane. Lease holders fence via random token; expired leases
902
+ // are reclaimed via atomic upsert inside a transaction.
903
+ db.exec(`
904
+ CREATE TABLE IF NOT EXISTS worker_leases (
905
+ worker_name TEXT PRIMARY KEY,
906
+ lease_token TEXT NOT NULL,
907
+ acquired_at TEXT NOT NULL,
908
+ expires_at TEXT NOT NULL
909
+ )
910
+ `);
857
911
  }
858
912
 
859
913
 
860
914
  // Per-database dimension cache (WeakMap keyed by db object — no collisions for in-memory DBs)
861
915
  const vecTableDimsCache = new WeakMap<Database, number>();
862
916
 
917
+ // v0.8.1 Ext 6b: per-database cache for the query_text column presence on
918
+ // context_usage. Set once at migration time so insertUsageFn can pick the
919
+ // correct INSERT shape without running PRAGMA on every write. Falls back
920
+ // to `false` (safe — equivalent to pre-migration behavior) when absent.
921
+ const contextUsageHasQueryTextCache = new WeakMap<Database, boolean>();
922
+
863
923
  function ensureVecTableInternal(db: Database, dimensions: number): void {
864
924
  if (vecTableDimsCache.get(db) === dimensions) return;
865
925
 
@@ -1687,6 +1747,13 @@ export type UsageRecord = {
1687
1747
  estimatedTokens: number;
1688
1748
  wasReferenced: number;
1689
1749
  turnIndex?: number;
1750
+ /**
1751
+ * v0.8.1 Ext 6b: raw user prompt for this turn. Written when the caller
1752
+ * wants the row to be usable for multi-turn lookback retrieval. Persisted
1753
+ * via `insertUsageFn` only when the `query_text` column is present on
1754
+ * `context_usage` (pre-migration stores degrade to "no prior query").
1755
+ */
1756
+ queryText?: string;
1690
1757
  };
1691
1758
 
1692
1759
  export type UsageRow = {
@@ -3904,10 +3971,33 @@ function getRecentSessionsFn(db: Database, limit: number): SessionRecord[] {
3904
3971
  // =============================================================================
3905
3972
 
3906
3973
  function insertUsageFn(db: Database, usage: UsageRecord): number {
3907
- db.prepare(`
3908
- INSERT INTO context_usage (session_id, timestamp, hook_name, injected_paths, estimated_tokens, was_referenced, turn_index)
3909
- VALUES (?, ?, ?, ?, ?, ?, ?)
3910
- `).run(usage.sessionId, usage.timestamp, usage.hookName, JSON.stringify(usage.injectedPaths), usage.estimatedTokens, usage.wasReferenced, usage.turnIndex ?? 0);
3974
+ // v0.8.1 Ext 6b: write query_text when the column is present AND the
3975
+ // caller provided one. The column presence is cached at migration time
3976
+ // in contextUsageHasQueryTextCache missing entries default to false
3977
+ // so ad-hoc DBs constructed outside createStore() degrade gracefully
3978
+ // to the pre-v0.8.1 INSERT shape.
3979
+ const hasQueryText = contextUsageHasQueryTextCache.get(db) ?? false;
3980
+ if (hasQueryText) {
3981
+ db.prepare(`
3982
+ INSERT INTO context_usage
3983
+ (session_id, timestamp, hook_name, injected_paths, estimated_tokens, was_referenced, turn_index, query_text)
3984
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3985
+ `).run(
3986
+ usage.sessionId,
3987
+ usage.timestamp,
3988
+ usage.hookName,
3989
+ JSON.stringify(usage.injectedPaths),
3990
+ usage.estimatedTokens,
3991
+ usage.wasReferenced,
3992
+ usage.turnIndex ?? 0,
3993
+ usage.queryText ?? null,
3994
+ );
3995
+ } else {
3996
+ db.prepare(`
3997
+ INSERT INTO context_usage (session_id, timestamp, hook_name, injected_paths, estimated_tokens, was_referenced, turn_index)
3998
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3999
+ `).run(usage.sessionId, usage.timestamp, usage.hookName, JSON.stringify(usage.injectedPaths), usage.estimatedTokens, usage.wasReferenced, usage.turnIndex ?? 0);
4000
+ }
3911
4001
  // Return the rowid of the just-inserted row for recall event linkage
3912
4002
  const row = db.prepare("SELECT last_insert_rowid() as id").get() as { id: number };
3913
4003
  return row.id;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ClawMem Worker Lease (v0.8.0 Ext 5)
3
+ *
4
+ * DB-backed exclusive lease for heavy-lane workers. Uses the `worker_leases`
5
+ * table (schema in store.ts) instead of module globals so multiple processes
6
+ * sharing a vault cannot run heavy maintenance concurrently.
7
+ *
8
+ * Lease lifecycle:
9
+ * 1. acquireWorkerLease inserts or reclaims an expired row via transaction
10
+ * and returns a random fencing token on success.
11
+ * 2. The holder runs its work.
12
+ * 3. releaseWorkerLease deletes the row only if the caller's token matches,
13
+ * so a lease reclaimed by another worker after TTL expiry cannot be
14
+ * torn down by the original holder.
15
+ *
16
+ * withWorkerLease wraps acquire/release around a callback; failure to acquire
17
+ * is a silent no-op (returns `{acquired: false}`) — callers should log a
18
+ * `skipped` journal row with reason `lease_unavailable`.
19
+ */
20
+
21
+ import { randomBytes } from "node:crypto";
22
+ import type { Store } from "./store.ts";
23
+
24
+ export interface LeaseAcquireResult {
25
+ acquired: boolean;
26
+ token?: string;
27
+ expiresAt?: string;
28
+ }
29
+
30
+ function nowIso(now: Date = new Date()): string {
31
+ return now.toISOString();
32
+ }
33
+
34
+ function futureIso(now: Date, ttlMs: number): string {
35
+ return new Date(now.getTime() + ttlMs).toISOString();
36
+ }
37
+
38
+ /**
39
+ * Attempt to acquire an exclusive lease on `workerName` for `ttlMs`.
40
+ *
41
+ * Returns `{acquired: true, token, expiresAt}` on success, or
42
+ * `{acquired: false}` if another worker holds a live (non-expired) lease.
43
+ *
44
+ * Race-safe under multi-process contention: uses a single
45
+ * `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` statement
46
+ * so the "no row → insert" and "expired row → update" paths cannot
47
+ * both fire for two concurrent callers. SQLite's changes() reports 1
48
+ * iff THIS call either inserted a fresh row or reclaimed an expired row;
49
+ * 0 means a live lease was held by someone else.
50
+ *
51
+ * Any SQLITE_BUSY / constraint failure is translated to
52
+ * `{ acquired: false }` so the advertised non-throw contract holds for
53
+ * callers that are layering `shouldRunHeavyMaintenance` above this.
54
+ */
55
+ export function acquireWorkerLease(
56
+ store: Store,
57
+ workerName: string,
58
+ ttlMs: number,
59
+ now: Date = new Date(),
60
+ ): LeaseAcquireResult {
61
+ if (ttlMs <= 0) {
62
+ throw new Error(`acquireWorkerLease: ttlMs must be positive, got ${ttlMs}`);
63
+ }
64
+ const token = randomBytes(16).toString("hex");
65
+ const acquiredAt = nowIso(now);
66
+ const expiresAt = futureIso(now, ttlMs);
67
+
68
+ try {
69
+ // Single-statement atomic acquire. The WHERE on the UPDATE clause
70
+ // only reclaims when the existing lease has expired (its expires_at
71
+ // <= our acquired_at); otherwise the ON CONFLICT DO UPDATE becomes
72
+ // a no-op and SQLite reports changes=0.
73
+ const result = store.db.prepare(
74
+ `INSERT INTO worker_leases
75
+ (worker_name, lease_token, acquired_at, expires_at)
76
+ VALUES (?, ?, ?, ?)
77
+ ON CONFLICT(worker_name) DO UPDATE SET
78
+ lease_token = excluded.lease_token,
79
+ acquired_at = excluded.acquired_at,
80
+ expires_at = excluded.expires_at
81
+ WHERE worker_leases.expires_at <= excluded.acquired_at`,
82
+ ).run(workerName, token, acquiredAt, expiresAt);
83
+
84
+ if (result.changes === 0) {
85
+ return { acquired: false };
86
+ }
87
+ return { acquired: true, token, expiresAt };
88
+ } catch (err) {
89
+ // Defensive fallback: any unexpected DB error (SQLITE_BUSY under
90
+ // extreme contention, constraint error from schema drift, etc.) is
91
+ // translated to a lease-unavailable result instead of bubbling up,
92
+ // so heavy-maintenance callers always get a deterministic
93
+ // "skipped/lease_unavailable" journal row.
94
+ console.error(
95
+ `[worker-lease] acquire error for ${workerName}: ${(err as Error).message}`,
96
+ );
97
+ return { acquired: false };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Release a lease if the caller's token still matches. Returns `true` if
103
+ * the lease was owned and deleted, `false` if a different token held it
104
+ * (e.g., TTL expired and another worker reclaimed).
105
+ */
106
+ export function releaseWorkerLease(
107
+ store: Store,
108
+ workerName: string,
109
+ token: string,
110
+ ): boolean {
111
+ const result = store.db.prepare(
112
+ `DELETE FROM worker_leases WHERE worker_name = ? AND lease_token = ?`,
113
+ ).run(workerName, token);
114
+ return result.changes > 0;
115
+ }
116
+
117
+ /**
118
+ * Run `fn` under an exclusive lease on `workerName`. If the lease cannot
119
+ * be acquired, returns `{acquired: false}` without invoking `fn`. The
120
+ * lease is always released in a `finally` block, even if `fn` throws.
121
+ *
122
+ * Rethrows any error from `fn` — callers are responsible for translating
123
+ * exceptions into journal rows.
124
+ */
125
+ export async function withWorkerLease<T>(
126
+ store: Store,
127
+ workerName: string,
128
+ ttlMs: number,
129
+ fn: () => Promise<T>,
130
+ ): Promise<{ acquired: boolean; result?: T }> {
131
+ const lease = acquireWorkerLease(store, workerName, ttlMs);
132
+ if (!lease.acquired || !lease.token) {
133
+ return { acquired: false };
134
+ }
135
+ try {
136
+ const result = await fn();
137
+ return { acquired: true, result };
138
+ } finally {
139
+ releaseWorkerLease(store, workerName, lease.token);
140
+ }
141
+ }