@staix/agent-mem 0.16.0 → 0.18.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/src/memory/gc.ts CHANGED
@@ -14,7 +14,8 @@ import { lifecycleResolver } from "./lifecycle.ts";
14
14
  * or rewritten (#13 AC5, #15 D7). An archived project is still bounded (cold shards shrink first); a forgotten one is skipped, never opened.
15
15
  */
16
16
 
17
- export interface GcShardPlan { key: string; state: "active" | "archived"; rows: number; bytes: number; capMb: number; retentionDays: number; expiredRows: number; overCap: boolean }
17
+ /** `trimRows`: unexpired rows, oldest first, that must go for the shard to fit its cap once the expired ones are gone. `evict()` removes them in whole batches. */
18
+ export interface GcShardPlan { key: string; state: "active" | "archived"; rows: number; bytes: number; capMb: number; retentionDays: number; expiredRows: number; overCap: boolean; trimRows: number }
18
19
  export interface GcShardResult { key: string; expired: number; trimmed: number; batches: number; remainingOverCap: boolean }
19
20
  export interface GcSkip { key: string; reason: string }
20
21
  export interface GcResult { dryRun: boolean; shards: GcShardResult[]; skipped: GcSkip[] }
@@ -26,15 +27,16 @@ export function planGc(base: string, opts: { now?: () => number } = {}): GcShard
26
27
  for (const shard of walkShards(base)) {
27
28
  const state = stateOf(shard.key);
28
29
  if (state === "forgotten") continue;
29
- let rows = 0, bytes = 0, expiredRows = 0, capMb = 0, retentionDays = 0;
30
+ let rows = 0, bytes = 0, expiredRows = 0, capMb = 0, retentionDays = 0, trimRows = 0;
30
31
  for (const dir of shard.machineDirs) {
31
32
  const config = readCaptureConfig(dir, base);
32
33
  capMb = config.capMb; retentionDays = config.retentionDays;
33
- const stats = captureShardStats(dir);
34
+ const stats = captureShardStats(dir), cutoff = new Date(now() - config.retentionDays * 24 * 3600_000).toISOString();
34
35
  rows += stats.rows; bytes += stats.bytes;
35
- expiredRows += expiredIn(dir, new Date(now() - config.retentionDays * 24 * 3600_000).toISOString());
36
+ expiredRows += expiredIn(dir, cutoff);
37
+ trimRows += trimIn(dir, cutoff, config.capMb * 1024 * 1024);
36
38
  }
37
- out.push({ key: shard.key, state, rows, bytes, capMb, retentionDays, expiredRows, overCap: capMb > 0 && bytes > capMb * 1024 * 1024 });
39
+ out.push({ key: shard.key, state, rows, bytes, capMb, retentionDays, expiredRows, overCap: capMb > 0 && bytes > capMb * 1024 * 1024, trimRows });
38
40
  }
39
41
  return out.sort((a, b) => a.key.localeCompare(b.key));
40
42
  }
@@ -48,6 +50,21 @@ function expiredIn(dir: string, cutoff: string): number {
48
50
  } catch { return 0; }
49
51
  }
50
52
 
53
+ /** Each machine directory has its own cap (evict() runs per store): the unexpired rows, oldest first, that must go to fit it. Read-only. */
54
+ function trimIn(dir: string, cutoff: string, capBytes: number): number {
55
+ if (capBytes <= 0 || !existsSync(join(dir, "capture.db"))) return 0;
56
+ try {
57
+ const db = new Database(join(dir, "capture.db"), { readonly: true });
58
+ try {
59
+ db.run("PRAGMA busy_timeout = 3000");
60
+ const kept = db.query("SELECT bytes FROM rows WHERE at >= ? ORDER BY at, id").all(cutoff) as { bytes: number }[];
61
+ let left = kept.reduce((sum, row) => sum + row.bytes, 0), n = 0;
62
+ for (const row of kept) { if (left <= capBytes) break; left -= row.bytes; n++; }
63
+ return n;
64
+ } finally { db.close(); }
65
+ } catch { return 0; }
66
+ }
67
+
51
68
  export function runGc(base: string, opts: GcOptions = {}): GcResult {
52
69
  const maxBatches = opts.maxBatchesPerShard ?? 1;
53
70
  if (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 1000) throw new Error("maxBatchesPerShard takes a number from 1 to 1000");
@@ -36,8 +36,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
36
36
  }
37
37
 
38
38
 
39
- export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
40
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
39
+ export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired";
40
+ const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
41
41
  /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
42
42
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
43
  const read = readCaptureDrops(base);
@@ -1,4 +1,4 @@
1
- import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
1
+ import { captureEventWeightSql, rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { chmodSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -204,8 +204,8 @@ export class ScreenedSearchIndex<T> {
204
204
  if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
205
205
  db.run("PRAGMA temp_store = FILE");
206
206
  db.run(this.available
207
- ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
208
- : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
207
+ ? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED, event UNINDEXED, session UNINDEXED)"
208
+ : "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL, event TEXT, session TEXT)");
209
209
  db.run("PRAGMA temp.cache_size = -2048");
210
210
  // A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
211
211
  // Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
@@ -214,13 +214,13 @@ export class ScreenedSearchIndex<T> {
214
214
  } catch (error) { db?.close(); throw error; }
215
215
  }
216
216
 
217
- append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
218
- const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
217
+ append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null; event?: string; session?: string }[]): void {
218
+ const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires, event, session) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
219
219
  this.db.transaction(() => {
220
220
  for (const row of rows) {
221
221
  const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
222
222
  // Invalid deadlines fail closed; this sentinel predates every representable Date.
223
- insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
223
+ insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline, row.event ?? null, row.session ?? null);
224
224
  }
225
225
  })();
226
226
  }
@@ -242,6 +242,31 @@ export class ScreenedSearchIndex<T> {
242
242
  });
243
243
  }
244
244
 
245
+ /** Weighted capture-only fill after the caller fixes the legacy noncapture slots. The caller has already screened every row before append. */
246
+ bestCaptureGroups(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
247
+ if (!Number.isSafeInteger(limit) || limit < 1 || useFts && !ftsTerms(query)) return [];
248
+ const instant = new Date(now).toISOString(), score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
249
+ const weight = captureEventWeightSql("event");
250
+ const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
251
+ const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
252
+ const where = "WHERE tier = 'capture' AND (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
253
+ const selected: { value: T; score: number; scoreDetails?: ScoreFactors }[] = [], groups = new Set<string>();
254
+ // Sort once in SQLite-owned TEMP storage, then stream past duplicate sessions without growing JS pages or re-sorting OFFSET windows.
255
+ const statement = this.db.query(`SELECT payload, ref, event, session, ${score} AS score${columns} FROM docs ${where} ORDER BY score * ${weight} DESC, ref`);
256
+ const orderedArgs = [instant, ...(explain ? [instant] : []), now, ...(useFts ? [ftsTerms(query)] : [])];
257
+ for (const row of statement.iterate(...orderedArgs) as Iterable<{ payload: string; ref: string; event: string | null; session: string | null; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }>) {
258
+ const session = typeof row.session === "string" && /^[a-f0-9]{16}$/.test(row.session) ? row.session : undefined;
259
+ const group = session ? `session:${session}` : `row:${row.ref}`;
260
+ if (groups.has(group)) continue;
261
+ groups.add(group);
262
+ const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
263
+ if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
264
+ selected.push(hit);
265
+ if (selected.length === limit) break;
266
+ }
267
+ return selected;
268
+ }
269
+
245
270
  close(): void {
246
271
  if (this.closed) return;
247
272
  this.closed = true;
@@ -250,6 +275,7 @@ export class ScreenedSearchIndex<T> {
250
275
  }
251
276
 
252
277
  interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; session?: string | null; subagent?: number | null }
278
+ export interface CaptureSearchOptions { weightEvents?: boolean; onePerSession?: boolean; now?: number }
253
279
 
254
280
  /**
255
281
  * The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
@@ -257,21 +283,43 @@ interface RawCaptureRow { id: number; at: string; project: string; agent: string
257
283
  * Rows older than `notBefore` (an ISO time) are left out; the store passes the shard's retention cutoff, so an expired
258
284
  * row is not recalled before a later append evicts it.
259
285
  */
260
- export function captureSearch(dir: string, q: string, limit: number, notBefore?: string): { row: CaptureRow; rank: number }[] {
286
+ export function captureSearch(dir: string, q: string, limit: number, notBefore?: string, options: CaptureSearchOptions = {}): { row: CaptureRow; rank: number }[] {
261
287
  const match = ftsTerms(q);
262
- if (!match || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
288
+ if (!match || !Number.isSafeInteger(limit) || limit < 1 || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
289
+ const weightEvents = options.weightEvents !== false, onePerSession = options.onePerSession ?? weightEvents;
290
+ const eventWeight = captureEventWeightSql("r.event");
291
+ const weightedOrder = `bm25(fts) * ${eventWeight}, r.id`;
292
+ const selected: { row: CaptureRow; rank: number }[] = [], groups = new Set<string>();
263
293
  let db: Database | undefined;
264
294
  try {
265
295
  db = new Database(join(dir, "capture.db"), { readonly: true });
266
296
  db.run("PRAGMA busy_timeout = 3000");
267
297
  if (!db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get()) return [];
268
- // `notBefore` (retention cutoff, `since`) runs inside the query: rows past it never take a LIMIT slot.
269
- const rows = (notBefore === undefined
270
- ? db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? ORDER BY rank LIMIT ?`).all(match, limit)
271
- : db.query(`SELECT r.*, bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE fts MATCH ? AND r.at >= ? ORDER BY rank LIMIT ?`).all(match, notBefore, limit)) as (RawCaptureRow & { rank: number })[];
272
- return rows.map((r) => ({ row: { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
273
- // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
274
- ...(typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? { session: r.session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) }, rank: r.rank }));
298
+ // Filter first, then sort once. In the grouped path the first row of each session is
299
+ // its best complete-score match, so stopping at limit cannot hide a better unread row.
300
+ const matchWhere = notBefore === undefined ? "fts MATCH ?" : "fts MATCH ? AND r.at >= ?";
301
+ const args: (string | number)[] = notBefore === undefined ? [match] : [match, notBefore];
302
+ const order = onePerSession
303
+ ? `${rankedScoreSql("bm25(fts)")} * ${weightEvents ? eventWeight : "1.0"} DESC, r.id`
304
+ : weightEvents ? weightedOrder : "rank";
305
+ // The capture tier is an existing score input, projected only for the shared SQL recipe.
306
+ const sql = `SELECT r.*, ${onePerSession ? "'capture' AS tier, " : ""}bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE ${matchWhere} ORDER BY ${order}${onePerSession ? "" : " LIMIT ?"}`;
307
+ args.push(onePerSession ? new Date(options.now ?? Date.now()).toISOString() : limit);
308
+ const statement = db.query(sql);
309
+ const rows = (onePerSession ? statement.iterate(...args) : statement.all(...args)) as Iterable<RawCaptureRow & { rank: number }>;
310
+ for (const r of rows) {
311
+ const session = typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? r.session : undefined;
312
+ if (onePerSession && session) {
313
+ if (groups.has(session)) continue;
314
+ groups.add(session);
315
+ }
316
+ const row: CaptureRow = { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
317
+ // #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
318
+ ...(session ? { session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) };
319
+ selected.push({ row, rank: r.rank });
320
+ if (selected.length === limit) break;
321
+ }
322
+ return selected;
275
323
  } catch { return []; }
276
324
  finally { db?.close(); }
277
325
  }
@@ -14,6 +14,16 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
14
14
  const MONTH_DAYS = 30;
15
15
  const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
16
16
  const DEFAULT_TIER_WEIGHT = 0.9;
17
+ // ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
18
+ // Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
19
+ export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
20
+ export function captureEventWeight(event: string): number {
21
+ return event === "Stop" || event === "UserPromptSubmit" ? CAPTURE_CONVERSATION_WEIGHT : 1;
22
+ }
23
+ /** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
24
+ export function captureEventWeightSql(eventColumn: string): string {
25
+ return `(CASE WHEN ${eventColumn} IN ('Stop', 'UserPromptSubmit') THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
26
+ }
17
27
  const TIER_FACTOR: ScoreExpr = { op: "tier" };
18
28
  const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
19
29
  const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
@@ -53,6 +63,9 @@ function sql(expr: ScoreExpr, rank: string): string {
53
63
  export function rankedScore(rank: number, tier: string, at: string, now: number): number {
54
64
  return numeric(SCORE, rank, tier, at, now);
55
65
  }
66
+ export function captureWeightedScore(rank: number, event: string, at: string, now: number): number {
67
+ return rankedScore(rank, "capture", at, now) * captureEventWeight(event);
68
+ }
56
69
  /** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
57
70
  export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
58
71
  export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
@@ -1094,6 +1094,7 @@ export class MemoryStore {
1094
1094
  for (const store of this.readStores()) store.syncSearchIndex();
1095
1095
  const now = this.now();
1096
1096
  const hits: SearchHit[] = [];
1097
+ let captureShard: string | undefined, captureNotBefore: string | undefined;
1097
1098
  // The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
1098
1099
  // starve this project's LIMIT.
1099
1100
  // Superseded, forgotten, expired and `since` rows are filtered after FTS, so a fixed candidate cap could be filled
@@ -1122,13 +1123,17 @@ export class MemoryStore {
1122
1123
  // #15 D4/T1: the capture tier is project-scoped; without a project key there is no shard to recall from.
1123
1124
  if (f.includeCapture !== false && f.project) {
1124
1125
  const shard = shardMachineDir(this.memoryBaseDir, resolveAlias(this.memoryBaseDir, f.project), this.machine.id);
1126
+ captureShard = shard;
1125
1127
  // The retention cutoff and `since` run in the capture query, so neither expired nor old rows take a candidate slot.
1126
1128
  // An out-of-range retention_days gives no cutoff rather than failing the whole search (eviction keeps its own check).
1127
1129
  const override = f.captureRetentionDays, days = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : readCaptureConfig(shard, this.memoryBaseDir).retentionDays;
1128
1130
  const cutoff = new Date(now - days * 24 * 3600_000);
1129
1131
  const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
1130
1132
  const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
1131
- for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
1133
+ captureNotBefore = since && (!retained || since > retained) ? since : retained;
1134
+ // First reproduce the old unweighted BM25 candidate window. Only these baseline capture slots may change;
1135
+ // curated membership and positions are not affected by conversational weighting or session collapse.
1136
+ for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), captureNotBefore, { weightEvents: false, onePerSession: false })) {
1132
1137
  const selected: SearchHit = { source: "capture", score: rankedScore(rank, "capture", row.at, now), capture: row };
1133
1138
  if (f.explain) selected.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, selected.score);
1134
1139
  hits.push(selected);
@@ -1144,7 +1149,23 @@ export class MemoryStore {
1144
1149
  }
1145
1150
  const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
1146
1151
  hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
1147
- return { engine: "fts5", hits: hits.slice(0, limit) };
1152
+ const baseline = hits.slice(0, limit), captureSlots = baseline.filter(hit => hit.source === "capture").length;
1153
+ if (!captureSlots || !captureShard) return { engine: "fts5", hits: baseline };
1154
+ // Stream session representatives in complete weighted-score order; the legacy
1155
+ // baseline above still owns noncapture slots and exposed scores remain unweighted.
1156
+ const weighted = captureSearch(captureShard, q, captureSlots, captureNotBefore,
1157
+ { weightEvents: true, onePerSession: true, now });
1158
+ const replacements = weighted.map(({ row, rank }) => {
1159
+ const score = rankedScore(rank, "capture", row.at, now), hit: SearchHit = { source: "capture", score, capture: row };
1160
+ if (f.explain) hit.scoreDetails = rankedScoreDetails("capture", rank, "capture", row.at, now, score);
1161
+ return hit;
1162
+ });
1163
+ let nextCapture = 0;
1164
+ const selected = baseline.flatMap(hit => {
1165
+ if (hit.source !== "capture") return [hit];
1166
+ const replacement = replacements[nextCapture++]; return replacement ? [replacement] : [];
1167
+ });
1168
+ return { engine: "fts5", hits: selected };
1148
1169
  }
1149
1170
 
1150
1171
  /** Bring the derived FTS copy level with the curated rows when a prune, a crash, a lock or a rebuild drifted them apart. */
@@ -17,6 +17,8 @@ import type { MemoryStore, View } from "./memory/store.ts";
17
17
  import { WorkListStore, type WorkListFields, type WorkListView } from "./memory/work-lists.ts";
18
18
 
19
19
  export interface BriefOptions { cwd: string; base?: string; project?: string; maxTokens?: number; policy?: StandalonePolicyOptions;
20
+ /** Repository-less hosts use only machine policy and omit the repo section, even if their home lies inside a repository. */
21
+ repository?: false;
20
22
  /** Host-only monotonic budget probe. The native hook still owns the hard child deadline. */
21
23
  timeRemaining?: () => number;
22
24
  /** Host-only parent CLI path: precomputed owned process identity and zero-wait stores. */
@@ -26,7 +28,7 @@ export interface BriefOptions { cwd: string; base?: string; project?: string; ma
26
28
  /** Test seam for the pending-candidate line's clock. */
27
29
  now?: () => number;
28
30
  /**
29
- * `amem doctor` only (F15): size the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
31
+ * Read-only diagnostic/console projection: build the brief without writing to the memory home. No reader lease is taken. The caller supplies the two sections that would otherwise
30
32
  * open a store read-write: `curated` is a store opened on a private copy of the logs (absent: the project has no curated notes, an empty complete section) and
31
33
  * `workBase` is a private base holding a copy of the project's and its aliases' work logs (absent: the work section is `unavailable`, never claimed complete).
32
34
  * The capture section opens this machine's capture database read-only, and only when it exists.
@@ -104,7 +106,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
104
106
  try {
105
107
  if (options.nativeSession && typeof options.timeRemaining !== "function") return result;
106
108
  const cwdForConfig = options.cwd, baseForConfig = canonicalPath(options.base ?? memoryHome());
107
- const configuredBrief = loadAmemConfig(baseForConfig, repoConfigRoot(cwdForConfig)).config.briefBytes;
109
+ const configuredBrief = loadAmemConfig(baseForConfig, options.repository === false ? undefined : repoConfigRoot(cwdForConfig)).config.briefBytes;
108
110
  // A caller's explicit budget wins over the default; the person's configured budget can only lower either.
109
111
  const maximum = Math.min(options.maxTokens ?? DEFAULT_TOKENS, configuredBrief ?? Number.MAX_SAFE_INTEGER);
110
112
  if (!Number.isSafeInteger(maximum) || maximum < 1) return result;
@@ -112,7 +114,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
112
114
  const requested = options.project ?? memoryProjectKey(cwd, { repositoryIsolation: true }), project = resolveAlias(base, requested);
113
115
  // #15 T5: a project the person archived or forgot yields no brief at all, before any lease, store or screen is opened.
114
116
  if (projectState(base, project) !== "active") return result;
115
- const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
117
+ const policy = createStandalonePolicy(configuredPolicyOptions(base, options.repository === false ? undefined : cwd, options.policy)), release = options.measureOnly ? Object.assign(() => {}, { verify: () => {} }) : registerReaderLease(base, options.nativeSession ? { signature: options.nativeSession.signature, busyTimeoutMs: 0 } : undefined);
116
118
  closers.push(release);
117
119
  const selection = readSelector(base).revision;
118
120
  const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
@@ -138,7 +140,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
138
140
  const coverage: BriefResult["coverage"] = { work: "complete", curated: "complete", capture: "complete" };
139
141
  // #15 T4: the bound repository's `.amem/log.jsonl`, only when the repository IS this project (AC3). The `lstat` probe comes FIRST and on every path (the SessionStart
140
142
  // worker builds this brief without `nativeSession`): a repository with no `.amem/log.jsonl` at or above the cwd costs a few syscalls and no git process.
141
- const repoRoot = !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
143
+ const repoRoot = options.repository === false || !repoLogNearby(cwd) ? undefined : options.projectFromCwd ? nearestRepoRootWithLog(cwd) : boundRepoRoot(cwd, base, project);
142
144
  if (repoLogPresent(repoRoot)) coverage.repo = "complete";
143
145
  const lines: string[] = [];
144
146
  const reasons: Partial<Record<Section, string>> = {};
@@ -179,7 +181,7 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
179
181
  await run("work", async () => {
180
182
  const measured = options.measureOnly;
181
183
  if (measured && !measured.workBase) { coverage.work = "unavailable"; return; }
182
- if (measured && !lstatSync(join(shardDir(measured.workBase!, project), "work", "log"), { throwIfNoEntry: false })) return; // no work log: the real brief shows an empty list
184
+ if (measured && ![project, ...aliases].some(key => lstatSync(join(shardDir(measured.workBase!, key), "work", "log"), { throwIfNoEntry: false }))) return; // no work log: the real brief shows an empty list
183
185
  const work = new WorkListStore({ base: measured?.workBase ?? base, project, aliases, policy: screen, ...(options.nativeSession ? { busyTimeoutMs: 0, timeRemaining: options.timeRemaining } : {}) }); closers.push(() => work.close());
184
186
  // Complete operation rows AND the composed view are screened before closed-item filtering or display caps.
185
187
  const views = await work.read(undefined, true); guard();
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { isDeepStrictEqual } from "node:util";
7
+
8
+ export interface ConsoleSetup { file: string; module: string; change: "add" | "replace" | "unchanged" | "remove" | "none"; reason?: string }
9
+
10
+ /**
11
+ * `amem setup console`: the acon plugin manifest `{api, tool, module, sha256}` at `$AGENTHUB_HOME/console/plugins/amem.json`, in a 0700 directory
12
+ * and a 0600 file, which is what agent-console's scanPlugins accepts. Preview writes nothing; undo removes only a manifest that points at this install.
13
+ */
14
+ export function consoleSetup(mode: "preview" | "apply" | "undo", env: Readonly<Record<string, string | undefined>> = process.env,
15
+ module = fileURLToPath(new URL("./ui/plugin.ts", import.meta.url))): ConsoleSetup {
16
+ const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
17
+ const dir = join(family, "console", "plugins"), file = join(dir, "amem.json");
18
+ // acon refuses a manifest directory or file that is a link, another kind of entry or another user's; so does setup, before writing anything.
19
+ for (const [path, kind] of [[dir, "directory"], [file, "file"]] as const) {
20
+ const info = lstatSync(path, { throwIfNoEntry: false });
21
+ if (!info) continue;
22
+ if (kind === "directory" ? !info.isDirectory() : !info.isFile()) throw Error(`${path}: acon needs a real ${kind}, not a link or another entry`);
23
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw Error(`${path} is owned by another user; acon would refuse it`);
24
+ }
25
+ const before = lstatSync(file, { throwIfNoEntry: false }) ? JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown> : undefined;
26
+ if (mode === "undo") {
27
+ if (!before) return { file, module, change: "none", reason: "no console manifest" };
28
+ if (before.module !== module) throw Error(`${file} points at another amem install (${String(before.module)}); left as is`);
29
+ unlinkSync(file);
30
+ return { file, module, change: "remove" };
31
+ }
32
+ const next = { api: 1, tool: "amem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
33
+ const mode7 = (path: string, want: number) => (lstatSync(path, { throwIfNoEntry: false })?.mode ?? want) & 0o777;
34
+ const modesOk = mode7(dir, 0o700) === 0o700 && mode7(file, 0o600) === 0o600;
35
+ if (isDeepStrictEqual(before, next) && modesOk) return { file, module, change: "unchanged" };
36
+ const change = before ? "replace" : "add";
37
+ if (mode === "apply") {
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ chmodSync(dir, 0o700);
40
+ const temp = `${file}.${process.pid}.tmp`;
41
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
42
+ renameSync(temp, file);
43
+ chmodSync(file, 0o600);
44
+ }
45
+ return { file, module, change, ...(modesOk ? {} : { reason: "permissions: plugin directory 0700, manifest 0600" }) };
46
+ }
47
+
48
+ export const consoleSetupText = (result: ConsoleSetup, mode: "preview" | "apply" | "undo"): string =>
49
+ `console plugin: ${mode === "preview" && result.change !== "unchanged" && result.change !== "none" ? `would ${result.change}` : result.change} ${result.file}${result.change === "remove" || result.change === "none" ? "" : ` -> ${result.module}`}${result.reason ? ` (${result.reason})` : ""}`
50
+ + (mode === "preview" && result.change !== "unchanged" ? "\nPreview only; add --apply to write it, --undo to remove it. Re-run after upgrading amem: acon refuses a module whose hash changed." : "");
@@ -0,0 +1,15 @@
1
+ .amem { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; }
2
+ .amem > .btn, .amem-card > .btn { align-self: flex-start; }
3
+ .amem-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
4
+ .amem-bar input[type="search"] { flex: 1; min-width: 0; max-width: 480px; }
5
+ .amem-inline { display: inline-flex; flex-wrap: wrap; gap: var(--acon-space-2); }
6
+ .amem-input { min-height: 32px; min-width: 0; max-width: 100%; padding: 0 10px; border: 1px solid var(--acon-line-2); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text); font: inherit; }
7
+ .amem-text { width: 100%; min-height: 10em; padding: var(--acon-space-2) 10px; font: var(--acon-text-sm)/1.5 var(--acon-font-mono); resize: vertical; }
8
+ .amem-form { display: flex; flex-direction: column; gap: var(--acon-space-2); }
9
+ .amem-form .btn { align-self: flex-start; }
10
+ .amem-sub { margin: 0; color: var(--acon-text-2); overflow-wrap: anywhere; }
11
+ .amem-card { display: flex; flex-direction: column; gap: var(--acon-space-2); }
12
+ .amem-card h3 { margin: 0; font-size: var(--acon-text-lg); }
13
+ .amem-diff { display: flex; flex-direction: column; gap: var(--acon-space-1); }
14
+ .amem-pre { margin: 0 0 var(--acon-space-2); padding: var(--acon-space-3); max-height: 40vh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--acon-surface-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: var(--acon-text-sm)/1.5 var(--acon-font-mono); }
15
+ @media (max-width: 600px) { .amem-inline { width: 100%; } .amem-inline .amem-input { flex: 1; } }
@@ -0,0 +1,154 @@
1
+ // amem console panels (acon api 1). Runs under the shell's strict CSP and renders data as text through host.ui only.
2
+ export default (host) => {
3
+ const { ui } = host;
4
+ const { h } = ui;
5
+ const root = h("div", { class: "amem" });
6
+ host.mount.append(root);
7
+ let panel = "review", params = {}, project = null;
8
+ const go = (p, extra = {}) => host.navigate(host.tool, p, { ...(project ? { project } : {}), ...extra });
9
+ const btn = (text, onclick, cls = "btn") => h("button", { type: "button", class: cls, text, onclick });
10
+ const day = (iso) => (typeof iso === "string" ? iso.slice(0, 16).replace("T", " ") : "");
11
+ const line = (text, max = 120) => { const flat = String(text ?? "").replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
12
+ const act = (input) => host.action({ ...input, ...(project ? { project } : {}) });
13
+
14
+ const picker = (s) => {
15
+ const list = s.projects ?? [];
16
+ if (list.length < 2) return list.length ? h("p", { class: "amem-sub", text: `Project ${list[0]}` }) : null;
17
+ const select = h("select", { name: "project", class: "amem-input", "aria-label": "Project", onchange: () => { project = select.value; go(panel); } },
18
+ list.map((key) => { const o = h("option", { value: key, text: key }); if (key === s.project) o.selected = true; return o; }));
19
+ return h("label", { class: "amem-bar" }, h("span", { text: "Project" }), select);
20
+ };
21
+
22
+ const reviewDetail = (r) => {
23
+ const reason = h("input", { type: "text", name: "reason", class: "amem-input", placeholder: "Reason (optional)", "aria-label": "Reject reason (optional)" });
24
+ const actions = [
25
+ r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null,
26
+ h("form", { class: "amem-inline", onsubmit: (e) => { e.preventDefault(); act({ action: "reject", id: r.id, ...(reason.value.trim() ? { reason: reason.value.trim() } : {}) }); } },
27
+ reason, h("button", { type: "submit", class: "btn", text: "Reject" })),
28
+ ].filter(Boolean);
29
+ const sections = [];
30
+ if (r.diff) sections.push({ title: "Now and proposed", open: true, body: h("div", { class: "amem-diff" },
31
+ h("strong", { text: "Now" }), h("pre", { class: "amem-pre", text: r.diff.now ?? "(the current note is not visible)" }),
32
+ h("strong", { text: "Proposed" }), h("pre", { class: "amem-pre", text: r.diff.proposed }),
33
+ h("p", { class: "amem-sub", text: `Reason: ${r.diff.reason}` })) });
34
+ const evidence = [...(r.evidence ?? []).map((ref) => ({ title: ref, sub: "evidence reference" })),
35
+ ...(r.targets ?? []).map((t) => ({ title: t.text ? line(t.title ?? t.text) : `mem:${t.id} (not visible)`, sub: t.at ? `mem:${t.id.slice(0, 8)} · ${t.kind} · ${day(t.at)}` : "" }))];
36
+ sections.push({ title: `Evidence (${evidence.length})`, open: evidence.length > 0, body: ui.rows(evidence, (e) => ui.row(e)) ?? ui.empty("No evidence attached", "This kind of candidate carries no references.") });
37
+ sections.push({ title: `Similar memories (${r.overlaps.length})`, open: r.overlaps.length > 0,
38
+ body: ui.rows(r.overlaps, (o) => ui.row({ title: o.title ?? "Untitled note", sub: `mem:${o.id.slice(0, 8)} · overlap ${o.score.toFixed(2)}` })) ?? ui.empty("No similar memories") });
39
+ if (r.edit !== null) {
40
+ const text = h("textarea", { name: "edit", class: "amem-input amem-text", "aria-label": "Edited text" });
41
+ text.value = r.edit;
42
+ sections.push({ title: "Edit, then accept", body: h("form", { class: "amem-form", onsubmit: (e) => { e.preventDefault(); act({ action: "edit_accept", id: r.id, text: text.value }); } },
43
+ text, h("button", { type: "submit", class: "btn", text: "Save edit and accept" })) });
44
+ }
45
+ return ui.detail({ kind: `${r.kind} candidate`, title: line(r.text, 90), state: { text: "pending", tone: "warn" }, whoseMove: "you", actions, summary: r.text,
46
+ provenance: [{ label: "Proposed", value: day(r.createdAt), at: r.createdAt }, { label: "Project", value: project ?? "" }, ...(r.noteKind ? [{ label: "Note kind", value: r.noteKind }] : [])],
47
+ sections, footer: [r.id] });
48
+ };
49
+
50
+ const views = {
51
+ review(s) {
52
+ const rows = s.rows ?? [], picked = rows.find((r) => r.id === s.selected);
53
+ const hidden = s.hidden ? ui.notice(`${s.hidden} candidate${s.hidden === 1 ? " is" : "s are"} withheld by the privacy screen; review ${s.hidden === 1 ? "it" : "them"} with amem review walk.`, "warn") : null;
54
+ if (picked) return [btn("Back to the queue", () => go("review")), reviewDetail(picked)];
55
+ return [hidden, ui.rows(rows, (r) => ui.row({ title: line(r.text), sub: `${r.kind} · ${day(r.createdAt)}${r.overlaps.length ? ` · ${r.overlaps.length} similar` : ""}`,
56
+ onOpen: () => go("review", { id: r.id }),
57
+ verbs: [r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null, btn("Reject", () => act({ action: "reject", id: r.id }))].filter(Boolean) }))
58
+ ?? ui.empty("No candidates waiting", "Agents propose memories with amem_remember; they wait here for your decision.")];
59
+ },
60
+ find(s) {
61
+ const q = h("input", { type: "search", name: "q", class: "amem-input", value: s.q ?? "", placeholder: "Search memory", "aria-label": "Search memory" });
62
+ q.value = s.q ?? "";
63
+ const form = h("form", { class: "amem-bar", role: "search", onsubmit: (e) => { e.preventDefault(); go("find", q.value.trim() ? { q: q.value.trim() } : {}); } }, q, h("button", { type: "submit", class: "btn", text: "Search" }));
64
+ const hits = s.hits ?? [];
65
+ return [form, s.q ? ui.rows(hits, (x) => {
66
+ const v = x.view, c = x.capture;
67
+ return ui.row({ title: line(v ? v.title ?? v.text : c?.excerpt), sub: [x.source, v?.kind ?? c?.event, day(v?.at ?? c?.at), v?.stale ? "stale" : ""].filter(Boolean).join(" · ") });
68
+ }) ?? ui.empty("Nothing found", "Search uses the same screened index as amem_search.") : ui.empty("Search this project's memory", "Results are screened exactly like amem_search.")];
69
+ },
70
+ timeline(s) {
71
+ return [ui.rows(s.entries ?? [], (x) => {
72
+ const c = x.capture;
73
+ return c ? ui.row({ title: line(c.excerpt), sub: `capture · ${c.event} · ${day(c.at)}` }) : ui.row({ title: line(x.title ?? x.text), sub: `${x.kind} · ${x.tier} · ${x.peer} · ${day(x.at)}` });
74
+ }) ?? ui.empty("Nothing on the timeline yet")];
75
+ },
76
+ work(s) {
77
+ const lists = s.lists ?? [];
78
+ return lists.length ? lists.map((l) => h("section", { class: "card amem-card" }, h("h3", { text: l.list }),
79
+ h("p", { class: "amem-sub", text: Object.entries(l.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") }),
80
+ ui.rows(l.items ?? [], (i) => ui.row({ title: i.task, sub: Object.entries(i.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") })) ?? ui.empty("No open items")))
81
+ : [ui.empty("No open work lists", "Agents write them with amem_work_state_write.")];
82
+ },
83
+ goals(s) {
84
+ return [ui.rows(s.goals ?? [], (g) => ui.row({ title: g.title, sub: [g.milestones.length ? `${g.milestones.length} milestones` : "", g.links.length ? `${g.links.length} linked tasks` : "", day(g.at)].filter(Boolean).join(" · "),
85
+ chip: { text: g.state, tone: g.state === "done" ? "ok" : g.state === "blocked" ? "err" : "neutral" } })) ?? ui.empty("No goals", "Create one with amem goal add.")];
86
+ },
87
+ capacity(s) {
88
+ if (s.withheld) return [ui.notice("The capacity report is withheld by the privacy screen.", "warn")];
89
+ const r = s.report;
90
+ const out = [];
91
+ if (r) {
92
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Capture" }),
93
+ h("dl", { class: "ui-provenance" },
94
+ h("dt", { text: "Rows" }), h("dd", { text: `${r.capture.rows} in ${r.capture.shards} shards, ${(r.capture.bytes / 1048576).toFixed(1)} MB` }),
95
+ h("dt", { text: "Growth" }), h("dd", { text: `${Math.round(r.capture.rowsPerDay)} rows/day` }),
96
+ h("dt", { text: "Disk free" }), h("dd", { text: `${(r.disk.freeBytes / 1073741824).toFixed(1)} GB` }),
97
+ h("dt", { text: "Current" }), h("dd", { text: `${r.current.capMb} MB, ${r.current.retentionDays} days` }),
98
+ h("dt", { text: "Proposed" }), h("dd", { text: `${r.proposal.capMb} MB, ${r.proposal.retentionDays} days` })),
99
+ h("p", { class: "amem-sub", text: r.proposal.reason }),
100
+ s.applyNeeded ? btn("Apply proposal...", () => host.action({ action: "capacity_apply" }), "btn primary") : ui.chip("current settings match the proposal", "ok")));
101
+ } else out.push(ui.empty("No memory home yet"));
102
+ const cutoff = () => new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
103
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Maintenance" }),
104
+ h("p", { class: "amem-sub", text: "Each action shows what it will change and runs only after you confirm." }),
105
+ h("div", { class: "amem-bar" },
106
+ btn("Evict expired capture...", () => host.action({ action: "gc" })),
107
+ project ? btn("Demote old notes...", () => act({ action: "decay_apply" })) : null,
108
+ project ? btn("Prune expired notes...", () => act({ action: "prune", cutoff: cutoff() })) : null,
109
+ project ? btn("Forget this project...", () => act({ action: "project_forget" }), "btn danger") : null)));
110
+ return out;
111
+ },
112
+ recap(s) {
113
+ const brief = s.brief;
114
+ if (!brief || typeof brief.context !== "string") return [ui.empty("Recap unavailable", "The current SessionStart brief could not be computed.")];
115
+ const coverage = brief.coverage ?? {};
116
+ const sections = ["work", "curated", "capture", "repo"].filter((key) => Object.hasOwn(coverage, key));
117
+ const rows = sections.map((key) => {
118
+ const value = coverage[key];
119
+ const detail = value && typeof value === "object" ? JSON.stringify(value) : String(value);
120
+ return { title: key, sub: line(detail, 180) };
121
+ });
122
+ const tokens = Number.isSafeInteger(brief.tokens) && brief.tokens >= 0 ? `At most ${brief.tokens} tokens` : "Token upper bound unavailable";
123
+ const generated = typeof s.generatedAt === "string" ? s.generatedAt : "unavailable";
124
+ return [
125
+ ui.notice("Computed now for this snapshot; this is not a copy of a prior session's injected brief.", "neutral"),
126
+ h("section", { class: "card amem-card" },
127
+ h("h3", { text: "Coverage" }),
128
+ ui.rows(rows, (row) => ui.row(row)) ?? ui.empty("No coverage details"),
129
+ h("dl", { class: "ui-provenance" },
130
+ h("dt", { text: "Status" }), h("dd", { text: brief.partial === true ? "Partial" : "Complete" }),
131
+ h("dt", { text: "Token bound" }), h("dd", { text: tokens }),
132
+ h("dt", { text: "Generated" }), h("dd", { text: generated }))),
133
+ s.repoAbsent === true ? h("p", { class: "amem-sub", text: "Repository section absent: the console has no repository binding." }) : null,
134
+ h("section", { class: "card amem-card" }, h("h3", { text: "SessionStart context" }), h("pre", { class: "amem-pre", text: brief.context })),
135
+ ].filter(Boolean);
136
+ },
137
+ };
138
+
139
+ host.onSnapshot((s) => {
140
+ project = s.project ?? null;
141
+ // A poll re-renders the view: carry typed values and focus over by field name.
142
+ const values = new Map([...root.querySelectorAll("input[name], textarea[name]")].map((el) => [el.name, el.value]));
143
+ const focused = root.contains(document.activeElement) ? document.activeElement.getAttribute("name") : null;
144
+ const body = s.error ? [ui.notice(s.error, "err")] : !s.projects?.length && panel !== "capacity" ? [ui.empty("No memory yet", "Projects appear once an agent captures or proposes memory.")] : views[panel]?.(s) ?? [ui.empty("Unknown panel")];
145
+ root.replaceChildren(...[picker(s), ...body].filter(Boolean));
146
+ for (const el of root.querySelectorAll("input[name], textarea[name]")) if (values.has(el.name) && el.name !== "q") el.value = values.get(el.name);
147
+ if (focused) root.querySelector(`[name="${focused}"]`)?.focus();
148
+ });
149
+ return {
150
+ show(id, p = {}) { panel = id; params = p; root.replaceChildren(); },
151
+ hide() {},
152
+ destroy() { root.remove(); },
153
+ };
154
+ };