@jbarket/loomai 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -12,6 +12,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.9.0] - 2026-10-24
16
+
17
+ ### Changed
18
+
19
+ - **loom's page, v2.** Home is now the tape: the last day's episodes grouped
20
+ by hour and filterable by where they happened, with tiles across the top
21
+ and top of mind and the people who need a look beside it. **Mind** lists
22
+ every memory by heat, marks exactly what the boot digest carries, and says
23
+ why each one has its heat. Pending proposals moved there, with ratify and
24
+ reject. One memory and People (each card, with what's newer than it) are
25
+ their own pages.
26
+ - **The page contract, v2 (docs/extensions.md, "The page").** A piece answers
27
+ `ui --json` with a manifest (a tab, a Home tile, "Your call" asks),
28
+ `ui page --json` with pages, and `ui act --json` to run actions. New blocks:
29
+ `kv`, `callout`, `filters`, `details`, `form`, `buttons`. Links can stay in
30
+ the piece, go to loom's pages, or carry a query. A first-version
31
+ `{sections}` answer still renders, as the piece's page.
32
+ - **Actions:** a form posts to `/ui/act/<source>` with an anti-forgery token
33
+ bound to the session cookie. Cross-site posts are refused. Fields go to
34
+ the piece on stdin only, and the result shows once without touching the
35
+ URL. A `secret` field never carries a value.
36
+ - **Sessions:** the cookie is now a random session id held in memory, and
37
+ signing out revokes it. Another site (or another port on the host) can
38
+ link to the page but not embed or fetch it. At most 6 pieces run at
39
+ once, and nothing a piece starts outlives its answer.
40
+ - **`/ui/sections.json` is gone;** `/ui/pieces.json` lists the installed
41
+ pieces' manifests, for scripts.
42
+ - **Top of mind reads heat live.** The boot digest and the page compute each
43
+ memory's heat from its timestamps when read, instead of trusting the stored
44
+ field the consolidation lane refreshes. Between passes the stored field went
45
+ stale, and Top of Mind fell back to write order.
46
+ - **Recall adds heat by rank instead of resetting every result to full heat.**
47
+ One vague query used to put ten unrelated memories at the top of mind. Now
48
+ the top result gets 0.5, the next 0.3 and 0.2, and the rest 0.1, decaying
49
+ on the memory's half-life and capped at 1. A write, an update or a repeat
50
+ remember still makes a memory fully hot. `last_accessed` still records
51
+ every look, for pruning. New columns `recall_heat` and `recall_heat_at`
52
+ (migration `add_recall_heat`) start at 0.
53
+
15
54
  ## [0.8.0] - 2026-10-24
16
55
 
17
56
  ### Added
@@ -607,7 +646,8 @@ Initial public release.
607
646
  stack and all added external-service dependencies or operational
608
647
  overhead.
609
648
 
610
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...HEAD
649
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...HEAD
650
+ [0.9.0]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...v0.9.0
611
651
  [0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
612
652
  [0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
613
653
  [0.6.0]: https://github.com/sleepunit-agents/loom/compare/v0.5.0...v0.6.0
package/README.md CHANGED
@@ -385,23 +385,33 @@ local, and `--url` on one of them is an error.
385
385
 
386
386
  ### loom's page — `/ui`
387
387
 
388
- The full-scope daemon serves a read-only web page at `/ui`. It shows top of
389
- mind, the last 24h of episodes from every body, proposals waiting for
390
- ratification, and person cards with their freshness. Below that, it shows a
391
- group for each installed **piece** that answers `ui --json`: a chat bridge's
392
- live conversations, the task queue, and so on. Nothing to configure: install
393
- a piece and it shows up ([`docs/extensions.md`](docs/extensions.md), "UI
394
- sections").
388
+ The full-scope daemon serves a web page at `/ui`. **Home** is the tape:
389
+ what every body did in the last day, by hour and by where it happened, with
390
+ top of mind and the people cards that need a look beside it. **Mind** lists
391
+ every memory by heat and says why each one has it, which is how you find a
392
+ memory that shouldn't be in what the agent wakes up holding. One memory and
393
+ **People** (each card, with what's newer than it) are a click away.
394
+
395
+ Everything else comes from installed **pieces**. A piece that answers
396
+ `ui --json` gets a tab, a tile on Home and items under "Your call", and its
397
+ own pages and forms: the task queue, the secrets store (write-only: add and
398
+ replace, never read back), the chat bridges. Nothing to configure: install a
399
+ piece and it shows up, uninstall it and it's gone. loom never names a piece
400
+ ([`docs/extensions.md`](docs/extensions.md), "The page").
395
401
 
396
402
  **The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
397
403
  (403), the same rule as `/cli`, because any site that DNS-rebinds a name to
398
404
  the loopback address could read an open page. Log in once with the token. The
399
- browser keeps a cookie under a key the daemon made at start: it's not the
405
+ browser gets a session: a random id the daemon keeps in memory. It's not the
400
406
  token, it can't be replayed against `/mcp` or `/cli`, and it can't be used to
401
- guess the token. It lasts 30 days, or until the daemon restarts. Scripts can
402
- send the bearer header to `/ui/sections.json` for the same data. The page has
403
- no script and is served under a CSP that forbids script and framing. Pieces
404
- never receive loom's bearer tokens.
407
+ guess the token. It lasts 30 days, until you sign out (which revokes it), or
408
+ until the daemon restarts. Every form carries an anti-forgery token bound to
409
+ that session, a cross-site post is refused, and another site can link to the
410
+ page but not embed or fetch it. A form's fields go to the piece on stdin, and loom never logs, keeps
411
+ or puts them in a URL. Scripts can send the bearer header instead (for
412
+ example `/ui/pieces.json` for the pieces' manifests). The page runs no script
413
+ and is served under a CSP that forbids script and framing. Pieces never
414
+ receive loom's bearer tokens.
405
415
 
406
416
  ### Behind a reverse proxy
407
417
 
@@ -496,8 +506,12 @@ wakes knowing what's top-of-mind without having to fish via `recall`.
496
506
  Each memory carries a stored salience "temperature" that **decays by a
497
507
  per-category half-life** (pursuit coolest-fastest at 7 days, through
498
508
  project, self/feedback, reference, up to user at 90 days) and **reheats
499
- on access** — a `recall` hit, a write, or an update bumps it back toward
500
- hot. The digest fills a token budget **hottest-first** and groups the
509
+ with use**. A write, an update or a repeat `remember` makes it fully hot.
510
+ A `recall` adds heat by rank: the top result gets 0.5, the next 0.3 and
511
+ 0.2, and everything below 0.1, capped at 1. So a memory is hot when it
512
+ keeps being the answer, not because it once turned up near the bottom of
513
+ someone's results. Heat is computed from those timestamps when the digest is read, so
514
+ it can't go stale if the consolidation lane skips a night. The digest fills a token budget **hottest-first** and groups the
501
515
  selected memories into tiers (Hot / Warm / Cool, labeled *Top of mind* /
502
516
  *Recent* / *Background*).
503
517
 
@@ -319,6 +319,24 @@ export const MIGRATIONS = [
319
319
  }).immediate();
320
320
  },
321
321
  },
322
+ {
323
+ id: 'add_recall_heat',
324
+ description: 'Add recall_heat + recall_heat_at: heat that recall adds by rank and that decays, ' +
325
+ 'so one incidental hit no longer makes a memory hot (t-1489)',
326
+ pending: (db) => !hasColumn(db, 'memories', 'recall_heat') || !hasColumn(db, 'memories', 'recall_heat_at'),
327
+ run: (db) => {
328
+ db.transaction(() => {
329
+ // Starts at 0 for every row: no recall history counts until recall
330
+ // adds to it. Heat falls back to when each memory was written or edited.
331
+ if (!hasColumn(db, 'memories', 'recall_heat')) {
332
+ db.prepare('ALTER TABLE memories ADD COLUMN recall_heat REAL NOT NULL DEFAULT 0').run();
333
+ }
334
+ if (!hasColumn(db, 'memories', 'recall_heat_at')) {
335
+ db.prepare('ALTER TABLE memories ADD COLUMN recall_heat_at TEXT').run();
336
+ }
337
+ }).immediate();
338
+ },
339
+ },
322
340
  ];
323
341
  /**
324
342
  * Apply pending migrations to a database.
@@ -45,9 +45,39 @@ export declare function applyObservation(current: number, kind: 'confirm' | 'con
45
45
  * goes through update() and stamps `observed_at`) is.
46
46
  */
47
47
  export declare function decayedConfidence(rawConfidence: number, lastTouchIso: string, category: string, nowMs: number): number;
48
+ /**
49
+ * How much one recall adds to a memory's recall heat, by its rank in the
50
+ * results: the top hit counts most, an incidental hit near the bottom barely
51
+ * at all. Recall used to reset every result to full heat, so one vague query
52
+ * put ten unrelated memories at the top of mind (t-1489).
53
+ */
54
+ export declare const RECALL_WEIGHTS: readonly number[];
55
+ export declare const RECALL_TAIL_WEIGHT = 0.1;
56
+ export declare function recallWeight(rank: number): number;
57
+ /** A stored recall heat, decayed by the category's half-life since it was last added to. */
58
+ export declare function decayedRecallHeat(value: number | null | undefined, atIso: string | null | undefined, category: string, nowMs: number): number;
59
+ /** The recall heat after one more hit at `rank`: decay what's there, add the weight, cap at 1. */
60
+ export declare function bumpedRecallHeat(value: number | null | undefined, atIso: string | null | undefined, category: string, rank: number, nowMs: number): number;
61
+ export interface HeatInput {
62
+ category: string;
63
+ created: string;
64
+ updated: string | null;
65
+ recall_heat?: number | null;
66
+ recall_heat_at?: string | null;
67
+ }
68
+ /**
69
+ * A memory's heat right now. Writing or editing it (including a repeat
70
+ * remember) makes it fully hot, decaying from then; recall adds to it by rank
71
+ * (recallWeight), so a memory stays hot by being used again and again, not by
72
+ * turning up once at the bottom of someone's results.
73
+ */
74
+ export declare function heatOf(r: HeatInput, nowMs: number): number;
75
+ /** Columns heatOf reads; older stores without the recall columns read them as null. */
76
+ export declare function heatColumns(db: Database): string;
48
77
  /**
49
78
  * Recompute and STORE the salience scalar for every non-archived memory. Called
50
- * by the consolidation lane (stored + lane-refresh, not computed-on-read).
79
+ * by the consolidation lane. The digest no longer depends on it (it reads heat
80
+ * live, see liveRows); the stored field is for anything that sorts in SQL.
51
81
  * Returns the number of memories recomputed.
52
82
  */
53
83
  export declare function recomputeSalience(db: Database, nowMs: number): number;
@@ -64,13 +94,17 @@ export interface DigestOptions {
64
94
  tokenBudget?: number;
65
95
  /** Rough chars-per-token for the budget estimate. */
66
96
  charsPerToken?: number;
97
+ /** The moment heat is read at (tests pin it). */
98
+ nowMs?: number;
67
99
  }
68
100
  /**
69
- * Assemble the boot digest from already-recomputed salience: order hottest-first,
101
+ * Assemble the boot digest from rows with heat already set: order hottest-first,
70
102
  * fill the token budget, group the included atoms under their tier headers. Pure
71
103
  * selection + ordering of authored content — no generated prose, no persisted
72
104
  * glue. Returns null when there is nothing to show.
73
105
  */
106
+ /** The rows the digest includes: hottest first, until the token budget is spent. */
107
+ export declare function pickForDigest<T extends DigestRow>(rows: T[], opts?: DigestOptions): T[];
74
108
  export declare function assembleDigest(rows: DigestRow[], opts?: DigestOptions): string | null;
75
109
  /** Lane entry point: recompute + store salience for a context. Returns count (or null if no store). */
76
110
  export declare function recomputeSalienceForContext(contextDir: string, nowMs: number): number | null;
@@ -88,9 +122,9 @@ export interface DigestAtom {
88
122
  * tier-labeled, ordered hot→cold, plus the total memory count. Same
89
123
  * assemble-not-generate view as the text digest — just shaped for rendering.
90
124
  */
91
- export declare function digestData(contextDir: string, limit?: number): {
125
+ export declare function digestData(contextDir: string, limit?: number, nowMs?: number): {
92
126
  atoms: DigestAtom[];
93
127
  total: number;
94
128
  } | null;
95
- /** Assemble the boot digest for a context from stored salience. Returns null if no store / nothing hot. */
129
+ /** Assemble the boot digest for a context, with heat read live. Returns null if no store / nothing hot. */
96
130
  export declare function digestForContext(contextDir: string, opts?: DigestOptions): string | null;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Salience temperature + the assembled boot digest (c-loom-memory: it-loom-salience).
3
3
  *
4
- * A memory's temperature is a stored scalar that DECAYS by a per-category
5
- * half-life and REHEATS on access (recall stamps last_accessed; write/update
6
- * stamps updated) — so a frequently-touched fact stays warm and an untouched one
7
- * cools. The consolidation lane recomputes the stored field; the digest assembler
4
+ * A memory's temperature DECAYS by a per-category half-life and REHEATS when
5
+ * it's written or edited (full heat) or recalled (heat added by rank, see
6
+ * recallWeight) — so a frequently-used fact stays warm and an untouched one
7
+ * cools. It's computed when read (heatOf); the stored scalar is a cache. The consolidation lane recomputes the stored field; the digest assembler
8
8
  * fills a token budget HOTTEST-FIRST from existing authored atoms, labeled into
9
9
  * tiers. It ASSEMBLES a view over authored memories — it never generates new
10
10
  * prose (the integrity line: loom holds the pen, unlike Anthropic's native digest
@@ -91,23 +91,56 @@ export function decayedConfidence(rawConfidence, lastTouchIso, category, nowMs)
91
91
  const temp = temperature(lastTouchIso, category, nowMs);
92
92
  return FEEDBACK_CONFIDENCE_DEFAULT + (rawConfidence - FEEDBACK_CONFIDENCE_DEFAULT) * temp;
93
93
  }
94
- /** Most-recent-touch timestamp for a row (recall/write reheat through these). */
95
- function lastTouch(r) {
96
- return r.last_accessed ?? r.updated ?? r.created;
94
+ /**
95
+ * How much one recall adds to a memory's recall heat, by its rank in the
96
+ * results: the top hit counts most, an incidental hit near the bottom barely
97
+ * at all. Recall used to reset every result to full heat, so one vague query
98
+ * put ten unrelated memories at the top of mind (t-1489).
99
+ */
100
+ export const RECALL_WEIGHTS = [0.5, 0.3, 0.2];
101
+ export const RECALL_TAIL_WEIGHT = 0.1;
102
+ export function recallWeight(rank) {
103
+ return RECALL_WEIGHTS[rank] ?? RECALL_TAIL_WEIGHT;
104
+ }
105
+ /** A stored recall heat, decayed by the category's half-life since it was last added to. */
106
+ export function decayedRecallHeat(value, atIso, category, nowMs) {
107
+ if (!value || !atIso)
108
+ return 0;
109
+ return Math.min(1, value * temperature(atIso, category, nowMs));
110
+ }
111
+ /** The recall heat after one more hit at `rank`: decay what's there, add the weight, cap at 1. */
112
+ export function bumpedRecallHeat(value, atIso, category, rank, nowMs) {
113
+ return Math.min(1, decayedRecallHeat(value, atIso, category, nowMs) + recallWeight(rank));
114
+ }
115
+ /**
116
+ * A memory's heat right now. Writing or editing it (including a repeat
117
+ * remember) makes it fully hot, decaying from then; recall adds to it by rank
118
+ * (recallWeight), so a memory stays hot by being used again and again, not by
119
+ * turning up once at the bottom of someone's results.
120
+ */
121
+ export function heatOf(r, nowMs) {
122
+ const written = temperature(r.updated ?? r.created, r.category, nowMs);
123
+ return Math.max(written, decayedRecallHeat(r.recall_heat, r.recall_heat_at, r.category, nowMs));
124
+ }
125
+ /** Columns heatOf reads; older stores without the recall columns read them as null. */
126
+ export function heatColumns(db) {
127
+ const have = hasColumn(db, 'memories', 'recall_heat');
128
+ return have ? 'created, updated, recall_heat, recall_heat_at' : 'created, updated, NULL AS recall_heat, NULL AS recall_heat_at';
97
129
  }
98
130
  /**
99
131
  * Recompute and STORE the salience scalar for every non-archived memory. Called
100
- * by the consolidation lane (stored + lane-refresh, not computed-on-read).
132
+ * by the consolidation lane. The digest no longer depends on it (it reads heat
133
+ * live, see liveRows); the stored field is for anything that sorts in SQL.
101
134
  * Returns the number of memories recomputed.
102
135
  */
103
136
  export function recomputeSalience(db, nowMs) {
104
137
  const rows = db
105
- .prepare('SELECT id, category, created, updated, last_accessed FROM memories WHERE archived = 0')
138
+ .prepare(`SELECT id, category, ${heatColumns(db)} FROM memories WHERE archived = 0`)
106
139
  .all();
107
140
  const upd = db.prepare('UPDATE memories SET salience = ? WHERE id = ?');
108
141
  const tx = db.transaction(() => {
109
142
  for (const r of rows)
110
- upd.run(temperature(lastTouch(r), r.category, nowMs), r.id);
143
+ upd.run(heatOf(r, nowMs), r.id);
111
144
  });
112
145
  // The nightly full-table recompute is the heaviest write in loom; wrap it so
113
146
  // a concurrent remember() call during the recompute retries instead of throwing.
@@ -126,23 +159,28 @@ function atomLine(r) {
126
159
  return `- **${r.title}** (${r.category}${tag}) — ${clipped}`;
127
160
  }
128
161
  /**
129
- * Assemble the boot digest from already-recomputed salience: order hottest-first,
162
+ * Assemble the boot digest from rows with heat already set: order hottest-first,
130
163
  * fill the token budget, group the included atoms under their tier headers. Pure
131
164
  * selection + ordering of authored content — no generated prose, no persisted
132
165
  * glue. Returns null when there is nothing to show.
133
166
  */
134
- export function assembleDigest(rows, opts = {}) {
167
+ /** The rows the digest includes: hottest first, until the token budget is spent. */
168
+ export function pickForDigest(rows, opts = {}) {
135
169
  const budgetChars = (opts.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * (opts.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN);
136
170
  const ordered = [...rows].sort((a, b) => b.salience - a.salience);
137
171
  const picked = [];
138
172
  let used = 0;
139
173
  for (const r of ordered) {
140
- const line = atomLine(r);
141
- if (used + line.length > budgetChars && picked.length > 0)
174
+ const len = atomLine(r).length;
175
+ if (used + len > budgetChars && picked.length > 0)
142
176
  break; // hottest-first to the cap
143
- picked.push({ tier: tierOf(r.salience), line });
144
- used += line.length + 1;
177
+ picked.push(r);
178
+ used += len + 1;
145
179
  }
180
+ return picked;
181
+ }
182
+ export function assembleDigest(rows, opts = {}) {
183
+ const picked = pickForDigest(rows, opts).map((r) => ({ tier: tierOf(r.salience), line: atomLine(r) }));
146
184
  if (picked.length === 0)
147
185
  return null;
148
186
  const tierOrder = ['Hot', 'Warm', 'Cool'];
@@ -185,12 +223,32 @@ export function recomputeSalienceForContext(contextDir, nowMs) {
185
223
  db.close();
186
224
  }
187
225
  }
226
+ /**
227
+ * Every live authored memory (not episodes) with its heat computed NOW from
228
+ * its timestamps, hottest first. The digest reads heat this way instead of
229
+ * trusting the stored field: the stored field only moves when the
230
+ * consolidation lane runs, and when the lane missed days (t-1488, and once
231
+ * before for two months) Top of Mind froze, with ties at 1.0 falling back to
232
+ * write order. The stored field stays, refreshed by the lane, for anything
233
+ * that sorts in SQL.
234
+ */
235
+ function liveRows(db, nowMs) {
236
+ const rows = db
237
+ .prepare(`SELECT title, category, content, salience AS stored, sourcing, ${heatColumns(db)} FROM memories WHERE archived = 0 AND category != ?`)
238
+ .all(EPISODE_CATEGORY);
239
+ return rows
240
+ .map(({ created, updated, recall_heat, recall_heat_at, ...r }) => ({
241
+ ...r,
242
+ salience: heatOf({ category: r.category, created, updated, recall_heat, recall_heat_at }, nowMs),
243
+ }))
244
+ .sort((a, b) => b.salience - a.salience);
245
+ }
188
246
  /**
189
247
  * Structured salience digest for a UI widget: the hottest authored atoms,
190
248
  * tier-labeled, ordered hot→cold, plus the total memory count. Same
191
249
  * assemble-not-generate view as the text digest — just shaped for rendering.
192
250
  */
193
- export function digestData(contextDir, limit = 40) {
251
+ export function digestData(contextDir, limit = 40, nowMs = Date.now()) {
194
252
  const db = openMemoriesDb(contextDir, true);
195
253
  if (!db)
196
254
  return null;
@@ -198,17 +256,16 @@ export function digestData(contextDir, limit = 40) {
198
256
  if (!hasColumn(db, 'memories', 'salience'))
199
257
  return { atoms: [], total: 0 };
200
258
  const total = db.prepare('SELECT COUNT(*) AS n FROM memories WHERE archived = 0').get().n;
201
- const rows = db
202
- .prepare('SELECT title, category, content, salience, sourcing FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT ?')
203
- .all(EPISODE_CATEGORY, limit);
204
- const atoms = rows.map((r) => ({ ...r, tier: tierOf(r.salience) }));
259
+ const atoms = liveRows(db, nowMs)
260
+ .slice(0, limit)
261
+ .map(({ stored: _stored, ...r }) => ({ ...r, tier: tierOf(r.salience) }));
205
262
  return { atoms, total };
206
263
  }
207
264
  finally {
208
265
  db.close();
209
266
  }
210
267
  }
211
- /** Assemble the boot digest for a context from stored salience. Returns null if no store / nothing hot. */
268
+ /** Assemble the boot digest for a context, with heat read live. Returns null if no store / nothing hot. */
212
269
  export function digestForContext(contextDir, opts = {}) {
213
270
  const db = openMemoriesDb(contextDir, true);
214
271
  if (!db)
@@ -218,14 +275,9 @@ export function digestForContext(contextDir, opts = {}) {
218
275
  // path can't migrate; the lane / normal backend connect will add the column).
219
276
  if (!hasColumn(db, 'memories', 'salience'))
220
277
  return null;
221
- const rows = db
222
- .prepare(
223
- // Episodes are the tape, not the digest: a fresh episode is always
224
- // salience 1.0 and would crowd Top of Mind with what just happened.
225
- // sourcing: LEFT JOIN-style null-safe select — old stores without the
226
- // column return NULL which DigestRow.sourcing accepts.
227
- 'SELECT title, category, content, salience, sourcing FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT 100')
228
- .all(EPISODE_CATEGORY);
278
+ // Episodes are the tape, not the digest: a fresh episode is always hot
279
+ // and would crowd Top of Mind with what just happened.
280
+ const rows = liveRows(db, opts.nowMs ?? Date.now()).slice(0, 100);
229
281
  return assembleDigest(rows, opts);
230
282
  }
231
283
  finally {
@@ -21,7 +21,7 @@ import { retryWrite } from './retry.js';
21
21
  import { computeUniqKey } from './dedup.js';
22
22
  import { effectiveScope, isVisibleInProject } from './scope.js';
23
23
  import { FEEDBACK_CATEGORY } from '../categories.js';
24
- import { FEEDBACK_CONFIDENCE_DEFAULT, applyObservation, decayedConfidence, } from './salience.js';
24
+ import { FEEDBACK_CONFIDENCE_DEFAULT, applyObservation, bumpedRecallHeat, decayedConfidence, } from './salience.js';
25
25
  function slugify(text) {
26
26
  return text
27
27
  .toLowerCase()
@@ -165,11 +165,20 @@ export class SqliteVecBackend {
165
165
  const results = hits.map((h, i) => rowToMatch(h.mem, h.distance, blended?.[i]));
166
166
  const hitIds = hits.map((h) => h.mem.id);
167
167
  if (hitIds.length > 0) {
168
- const now = new Date().toISOString();
169
- const stamp = this.db.prepare('UPDATE memories SET last_accessed = ? WHERE id = ?');
168
+ // last_accessed still records that someone looked (prune's staleness
169
+ // reads it). Heat is separate: each hit adds to recall_heat by its rank,
170
+ // so the top result warms a lot and an incidental one barely (t-1489).
171
+ const nowMs = Date.now();
172
+ const now = new Date(nowMs).toISOString();
173
+ const read = this.db.prepare('SELECT category, recall_heat, recall_heat_at FROM memories WHERE id = ?');
174
+ const stamp = this.db.prepare('UPDATE memories SET last_accessed = ?, recall_heat = ?, recall_heat_at = ? WHERE id = ?');
170
175
  const tx = this.db.transaction((ids) => {
171
- for (const id of ids)
172
- stamp.run(now, id);
176
+ ids.forEach((id, rank) => {
177
+ const r = read.get(id);
178
+ if (!r)
179
+ return;
180
+ stamp.run(now, bumpedRecallHeat(r.recall_heat, r.recall_heat_at, r.category, rank, nowMs), now, id);
181
+ });
173
182
  });
174
183
  retryWrite(() => tx(hitIds));
175
184
  }
package/dist/ui/core.d.ts CHANGED
@@ -1,9 +1,21 @@
1
- import type { Section } from './sections.js';
1
+ import { type ActResult, type Page, type Tile } from './vocab.js';
2
2
  /** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
3
3
  export declare function uiTimeZone(): string;
4
4
  /** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
5
5
  export declare function agentName(contextDir: string): string;
6
- export declare function coreSections(contextDir: string, opts?: {
6
+ export interface CoreOptions {
7
7
  timeZone?: string;
8
8
  nowMs?: number;
9
- }): Section[];
9
+ }
10
+ /** "Sat Oct 24, 12:09 pm" */
11
+ export declare function when(iso: string | null, tz: string): string;
12
+ export interface Home {
13
+ tile: Tile;
14
+ page: Page;
15
+ }
16
+ /** Home: the tape is the page. Top of mind and people sit beside it. */
17
+ export declare function homePage(contextDir: string, query: Record<string, string>, opts?: CoreOptions): Home;
18
+ /** One of loom's pages by path (not Home), or null when there's no such page. */
19
+ export declare function corePage(contextDir: string, path: string, query: Record<string, string>, opts?: CoreOptions): Page | null;
20
+ /** Run one of loom's own actions from the page. */
21
+ export declare function coreAct(contextDir: string, action: string, fields: Record<string, string>): Promise<ActResult>;