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/AGENTS.md +12 -3
- package/CLAUDE.md +12 -3
- package/README.md +29 -0
- package/SKILL.md +10 -3
- package/package.json +1 -1
- package/src/consolidation.ts +146 -16
- package/src/hooks/context-surfacing.ts +160 -16
- package/src/hooks.ts +9 -1
- package/src/maintenance.ts +540 -0
- package/src/mcp.ts +34 -0
- package/src/store.ts +96 -6
- package/src/worker-lease.ts +141 -0
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
|
-
|
|
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
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
+
}
|