@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/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -1
- package/package.json +6 -3
- package/src/cli.ts +6 -1
- package/src/commands-table.ts +1 -1
- package/src/hook-spool.ts +41 -14
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +43 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +64 -12
- package/src/memory/gc.ts +22 -5
- package/src/memory/metrics.ts +2 -2
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +13 -0
- package/src/memory/store.ts +23 -2
- package/src/session-brief.ts +7 -5
- package/src/setup-console.ts +50 -0
- package/src/ui/panel.css +15 -0
- package/src/ui/panel.js +154 -0
- package/src/ui/plugin.ts +507 -0
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
|
-
|
|
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,
|
|
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");
|
package/src/memory/metrics.ts
CHANGED
|
@@ -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);
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -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
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 } {
|
package/src/memory/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/session-brief.ts
CHANGED
|
@@ -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
|
-
*
|
|
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!,
|
|
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." : "");
|
package/src/ui/panel.css
ADDED
|
@@ -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; } }
|
package/src/ui/panel.js
ADDED
|
@@ -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
|
+
};
|