@jbarket/loomai 0.7.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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * loom's own pages: what only loom knows. Home (the tape, top of mind, the
3
+ * people who need a look), Mind (every memory's heat and why), one memory,
4
+ * People and one person, plus the actions on them (archive a memory, ratify
5
+ * or reject a proposal). Built in the same vocabulary the pieces use, so one
6
+ * renderer draws everything. Nothing here names a piece.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { listProposals, rejectProposal, ratifyProposal, UnknownProposalError } from '../backends/proposals.js';
11
+ import { fieldState } from '../backends/people.js';
12
+ import { listPeople, readPerson } from '../tools/person.js';
13
+ import { archive } from '../tools/archive.js';
14
+ import { heatList, memoriesAbout, memoryByRef, tapeRows } from './data.js';
15
+ import { validPath } from './vocab.js';
16
+ const TAPE_HOURS = 24;
17
+ const TAPE_MAX_HOURS = 24 * 7;
18
+ const MIND_SHOWN = 40;
19
+ const HOME_MIND = 5;
20
+ /** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
21
+ export function uiTimeZone() {
22
+ for (const z of [process.env.LOOM_TIMEZONE, process.env.TZ, 'America/Chicago']) {
23
+ if (!z)
24
+ continue;
25
+ try {
26
+ new Intl.DateTimeFormat('en-US', { timeZone: z });
27
+ return z;
28
+ }
29
+ catch { /* next */ }
30
+ }
31
+ return 'UTC';
32
+ }
33
+ /** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
34
+ export function agentName(contextDir) {
35
+ try {
36
+ const line = readFileSync(join(contextDir, 'IDENTITY.md'), 'utf-8').split('\n').find((l) => /^#\s+\S/.test(l));
37
+ if (line)
38
+ return line.replace(/^#\s+/, '').trim();
39
+ }
40
+ catch { /* no identity file */ }
41
+ return contextDir.split('/').filter(Boolean).pop() ?? 'loom';
42
+ }
43
+ function parts(iso, tz) {
44
+ const d = new Date(iso);
45
+ const f = (o) => new Intl.DateTimeFormat('en-US', { timeZone: tz, ...o }).format(d);
46
+ return {
47
+ day: f({ year: 'numeric', month: '2-digit', day: '2-digit' }),
48
+ weekday: f({ weekday: 'short' }),
49
+ date: f({ month: 'short', day: 'numeric' }),
50
+ time: f({ hour: 'numeric', minute: '2-digit' }).toLowerCase().replace(/\s/g, ' '),
51
+ hour: f({ hour: 'numeric' }).toLowerCase(),
52
+ };
53
+ }
54
+ /** "Sat Oct 24, 12:09 pm" */
55
+ export function when(iso, tz) {
56
+ if (!iso || Number.isNaN(Date.parse(iso)))
57
+ return 'never';
58
+ const p = parts(iso, tz);
59
+ return `${p.weekday} ${p.date}, ${p.time}`;
60
+ }
61
+ /** "today 12:09 pm", "yesterday", "Oct 19" */
62
+ function ago(iso, tz, nowMs) {
63
+ if (!iso || Number.isNaN(Date.parse(iso)))
64
+ return 'never';
65
+ const p = parts(iso, tz);
66
+ const today = parts(new Date(nowMs).toISOString(), tz).day;
67
+ const yesterday = parts(new Date(nowMs - 86_400_000).toISOString(), tz).day;
68
+ if (p.day === today)
69
+ return `today ${p.time}`;
70
+ if (p.day === yesterday)
71
+ return `yesterday ${p.time}`;
72
+ return p.date;
73
+ }
74
+ const heatTone = (h) => (h >= 0.6 ? 'warn' : h < 0.2 ? 'muted' : undefined);
75
+ /** The plain-language reason a memory has the heat it has. */
76
+ function why(m, tz, nowMs, people) {
77
+ const written = m.updated ? `Edited ${ago(m.updated, tz, nowMs)}.` : `Written ${ago(m.created, tz, nowMs)}.`;
78
+ const recalled = m.recallHeat >= 0.01
79
+ ? ` Being recalled adds ${m.recallHeat.toFixed(2)} (last ${ago(m.lastAccessed, tz, nowMs)}): the top result of a recall adds the most, an incidental one little.`
80
+ : m.lastAccessed ? '' : ' Never recalled.';
81
+ const source = m.recallHeat > m.writtenHeat ? 'recall' : 'when it was written';
82
+ const decay = ` ${m.category[0].toUpperCase()}${m.category.slice(1)} memories cool by half every ${m.halfLifeDays} days; it's at ${m.heat.toFixed(2)} now, from ${source}.`;
83
+ const where = m.inDigest ? ' That puts it in what I wake up holding.' : ' That\'s below what I wake up holding.';
84
+ const about = m.person
85
+ ? ` It's about ${people.get(m.person) ?? m.person}, but nothing limits it to conversations with them.`
86
+ : '';
87
+ return `${written}${recalled}${decay}${where}${about}`;
88
+ }
89
+ function memoryDetails(m, tz, nowMs, people) {
90
+ return {
91
+ type: 'details',
92
+ summary: m.title,
93
+ meta: `${m.category} · ${m.heat.toFixed(2)}${m.inDigest ? ' · at wake' : ''}`,
94
+ blocks: [
95
+ { type: 'text', text: why(m, tz, nowMs, people) },
96
+ {
97
+ type: 'kv',
98
+ items: [
99
+ { label: 'heat now', value: m.heat.toFixed(2) },
100
+ { label: 'from writing', value: m.writtenHeat.toFixed(2) },
101
+ { label: 'from recall', value: m.recallHeat.toFixed(2) },
102
+ ...(Math.abs(m.stored - m.heat) > 0.05 ? [{ label: 'stored (lane)', value: m.stored.toFixed(2) }] : []),
103
+ { label: 'half-life', value: `${m.halfLifeDays} days` },
104
+ { label: 'sourcing', value: m.sourcing ?? 'unknown' },
105
+ ...(m.project ? [{ label: 'project', value: m.project }] : []),
106
+ ...(m.person ? [{ label: 'about', value: people.get(m.person) ?? m.person, page: personPage(m.person) }] : []),
107
+ ],
108
+ },
109
+ ...(memoryPage(m.ref) ? [{ type: 'link', text: 'Open memory', page: memoryPage(m.ref) }] : []),
110
+ ],
111
+ };
112
+ }
113
+ function memoryPage(ref) {
114
+ const p = `memory/${ref}`;
115
+ return validPath(p) ? p : undefined;
116
+ }
117
+ function personPage(slug) {
118
+ const p = `people/${slug}`;
119
+ return validPath(p) ? p : undefined;
120
+ }
121
+ function peopleNames(contextDir) {
122
+ return new Map(listPeople(contextDir).map((p) => [p.slug, p.name]));
123
+ }
124
+ function surfaceOf(where) {
125
+ return where.split(/[:/]/)[0] || '?';
126
+ }
127
+ /** Home: the tape is the page. Top of mind and people sit beside it. */
128
+ export function homePage(contextDir, query, opts = {}) {
129
+ const tz = opts.timeZone ?? uiTimeZone();
130
+ const nowMs = opts.nowMs ?? Date.now();
131
+ const hours = Math.min(TAPE_MAX_HOURS, Math.max(TAPE_HOURS, Number.parseInt(query.hours ?? '', 10) || TAPE_HOURS));
132
+ const all = tapeRows(contextDir, hours, nowMs);
133
+ const surfaces = [...new Set(all.map((e) => surfaceOf(e.where)))].sort();
134
+ const pick = query.where && surfaces.includes(query.where) ? query.where : '';
135
+ const tape = pick ? all.filter((e) => surfaceOf(e.where) === pick) : all;
136
+ // Group by the hour each happened in, newest first.
137
+ const groups = [];
138
+ const today = parts(new Date(nowMs).toISOString(), tz).day;
139
+ for (const e of tape) {
140
+ const p = parts(e.created, tz);
141
+ const label = p.day === today ? p.hour : `${p.weekday} ${p.hour}`;
142
+ const last = groups[groups.length - 1];
143
+ if (last && last.label === label)
144
+ last.items.push(e);
145
+ else
146
+ groups.push({ label, items: [e] });
147
+ }
148
+ const tapeBlocks = [];
149
+ if (surfaces.length > 1) {
150
+ tapeBlocks.push({ type: 'filters', param: 'where', options: [{ value: '', label: 'Everywhere' }, ...surfaces.map((s) => ({ value: s, label: s }))] });
151
+ }
152
+ for (const g of groups) {
153
+ tapeBlocks.push({ type: 'text', text: g.label, tone: 'muted' });
154
+ tapeBlocks.push({
155
+ type: 'list',
156
+ items: g.items.map((e) => ({ text: e.title, meta: `${parts(e.created, tz).time} · ${e.where}`, page: memoryPage(e.ref) })),
157
+ });
158
+ }
159
+ if (!tape.length)
160
+ tapeBlocks.push({ type: 'text', text: pick ? `Nothing from ${pick} in the last ${hours}h.` : `Nothing in the last ${hours}h.`, tone: 'muted' });
161
+ if (hours < TAPE_MAX_HOURS) {
162
+ tapeBlocks.push({ type: 'link', text: 'Show earlier', page: `?hours=${Math.min(TAPE_MAX_HOURS, hours * 2)}${pick ? `&where=${pick}` : ''}` });
163
+ }
164
+ const heat = heatList(contextDir, { limit: HOME_MIND, nowMs });
165
+ const people = listPeople(contextDir);
166
+ const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis);
167
+ const sources = surfaces.length;
168
+ const sections = [
169
+ {
170
+ id: 'tape',
171
+ title: 'What\'s happening',
172
+ blocks: [
173
+ { type: 'text', text: `${all.length} thing${all.length === 1 ? '' : 's'} in the last ${hours === 24 ? 'day' : `${hours}h`}, from ${sources} place${sources === 1 ? '' : 's'}.`, tone: 'muted' },
174
+ ...tapeBlocks,
175
+ ],
176
+ },
177
+ {
178
+ id: 'mind',
179
+ title: 'On my mind',
180
+ side: true,
181
+ blocks: heat.rows.length
182
+ ? [{ type: 'list', items: heat.rows.map((m) => ({ text: m.title, meta: `${m.category} · ${m.heat.toFixed(2)}`, page: memoryPage(m.ref) })) },
183
+ { type: 'link', text: 'Why these?', page: 'mind' }]
184
+ : [{ type: 'text', text: 'Nothing hot yet.', tone: 'muted' }],
185
+ },
186
+ ];
187
+ if (people.length) {
188
+ sections.push({
189
+ id: 'people',
190
+ title: 'People',
191
+ side: true,
192
+ blocks: attention.length
193
+ ? [{
194
+ type: 'list',
195
+ items: attention.map((p) => ({
196
+ text: p.name,
197
+ meta: [
198
+ p.memories_since_synthesis ? `${p.memories_since_synthesis} new memor${p.memories_since_synthesis === 1 ? 'y' : 'ies'} not on the card` : '',
199
+ p.stale ? `${p.stale} stale` : '',
200
+ p.unverified ? `${p.unverified} unverified` : '',
201
+ ].filter(Boolean).join(' · '),
202
+ tone: 'warn',
203
+ page: personPage(p.slug),
204
+ })),
205
+ },
206
+ { type: 'link', text: `All ${people.length} cards`, page: 'people' }]
207
+ : [{ type: 'text', text: `All ${people.length} cards are current.`, tone: 'muted' }, { type: 'link', text: 'Open People', page: 'people' }],
208
+ });
209
+ }
210
+ return {
211
+ tile: {
212
+ label: 'Memory',
213
+ value: heat.total.toLocaleString('en-US'),
214
+ detail: `${all.length} episodes today · ${people.length} people`,
215
+ page: 'mind',
216
+ },
217
+ page: { title: 'Home', sections },
218
+ };
219
+ }
220
+ // ─── Mind ────────────────────────────────────────────────────────────────────
221
+ function mindPage(contextDir, query, tz, nowMs) {
222
+ const categories = ['user', 'feedback', 'project', 'self', 'reference', 'pursuit'];
223
+ const category = categories.includes(query.category ?? '') ? query.category : undefined;
224
+ const list = heatList(contextDir, { limit: MIND_SHOWN, category, nowMs });
225
+ const names = peopleNames(contextDir);
226
+ const pending = listProposals(contextDir).filter((p) => p.status === 'pending');
227
+ const atWake = list.rows.filter((m) => m.inDigest);
228
+ const below = list.rows.filter((m) => !m.inDigest);
229
+ const sections = [];
230
+ if (pending.length) {
231
+ sections.push({
232
+ id: 'proposals',
233
+ title: `${pending.length} proposal${pending.length === 1 ? '' : 's'} waiting`,
234
+ blocks: [
235
+ { type: 'text', text: 'Drafted memories that aren\'t real until they\'re ratified. Recall can\'t see them.', tone: 'muted' },
236
+ ...pending.map((p) => ({
237
+ type: 'details',
238
+ summary: p.title ?? '(untitled)',
239
+ meta: `#${p.id} · ${p.category ?? '?'}${p.source ? ` · from ${p.source}` : ''} · ${ago(p.created, tz, nowMs)}`,
240
+ blocks: [
241
+ { type: 'text', text: p.content ?? '' },
242
+ { type: 'buttons', items: [
243
+ { action: 'ratify', label: 'Ratify', tone: 'primary', fields: { id: String(p.id) } },
244
+ { action: 'reject', label: 'Reject', tone: 'danger', fields: { id: String(p.id) } },
245
+ ] },
246
+ ],
247
+ })),
248
+ ],
249
+ });
250
+ }
251
+ sections.push({
252
+ id: 'filter',
253
+ blocks: [
254
+ { type: 'filters', param: 'category', options: [{ value: '', label: 'Everything' }, ...categories.map((c) => ({ value: c, label: c }))] },
255
+ { type: 'text', text: `${list.total.toLocaleString('en-US')} memories · ${list.inDigest} load at wake · heat is computed from timestamps when it's read`, tone: 'muted' },
256
+ ],
257
+ });
258
+ sections.push({
259
+ id: 'wake',
260
+ title: category ? `At wake: ${category}` : 'What I wake up holding',
261
+ blocks: atWake.length ? atWake.map((m) => memoryDetails(m, tz, nowMs, names)) : [{ type: 'text', text: 'None of these load at wake.', tone: 'muted' }],
262
+ });
263
+ if (below.length) {
264
+ sections.push({
265
+ id: 'below',
266
+ title: 'Just below the line',
267
+ blocks: below.map((m) => memoryDetails(m, tz, nowMs, names)),
268
+ });
269
+ }
270
+ return {
271
+ title: 'On my mind',
272
+ intro: 'What I wake up holding. Each memory\'s heat decays by its category\'s half-life and comes back when it\'s recalled or edited. If something here doesn\'t belong, open it and see why.',
273
+ sections,
274
+ };
275
+ }
276
+ // ─── One memory ──────────────────────────────────────────────────────────────
277
+ function memoryPageFor(contextDir, ref, tz, nowMs) {
278
+ const m = memoryByRef(contextDir, ref, nowMs);
279
+ if (!m)
280
+ return { title: 'Not found', sections: [] };
281
+ const names = peopleNames(contextDir);
282
+ const episode = m.category === 'episode';
283
+ const main = [
284
+ { type: 'text', text: m.content },
285
+ ...(m.person ? [{ type: 'kv', items: [{ label: 'about', value: names.get(m.person) ?? m.person, page: personPage(m.person) }] }] : []),
286
+ { type: 'buttons', items: [{ action: 'archive', label: 'Archive', tone: 'danger', fields: { ref: m.ref } }] },
287
+ ];
288
+ const record = {
289
+ type: 'kv',
290
+ items: [
291
+ { label: 'category', value: m.category },
292
+ { label: 'written', value: when(m.created, tz) },
293
+ { label: 'last recalled', value: when(m.lastAccessed, tz) },
294
+ { label: 'edited', value: when(m.updated, tz) },
295
+ { label: 'seen', value: `${m.timesSeen} time${m.timesSeen === 1 ? '' : 's'}` },
296
+ { label: 'sourcing', value: m.sourcing ?? 'unknown' },
297
+ { label: 'scope', value: m.scope ?? (m.project ? 'project' : 'global') },
298
+ ...(m.project ? [{ label: 'project', value: m.project }] : []),
299
+ { label: 'expires', value: m.expiresAt ? when(m.expiresAt, tz) : 'never' },
300
+ { label: 'ref', value: m.ref },
301
+ ],
302
+ };
303
+ const sections = [{ id: 'memory', blocks: main }];
304
+ if (!episode) {
305
+ sections.push({
306
+ id: 'heat',
307
+ title: 'Heat',
308
+ side: true,
309
+ blocks: [
310
+ { type: 'stats', items: [{ label: 'now', value: m.heat.toFixed(2), tone: heatTone(m.heat) }, { label: 'half-life', value: `${m.halfLifeDays}d` }] },
311
+ { type: 'text', text: why(m, tz, nowMs, names), tone: 'muted' },
312
+ ],
313
+ });
314
+ }
315
+ sections.push({ id: 'record', title: 'Record', side: true, blocks: [record] });
316
+ return { title: m.title, sections };
317
+ }
318
+ // ─── People ──────────────────────────────────────────────────────────────────
319
+ function value(v) {
320
+ return Array.isArray(v) ? v.join(', ') : String(v);
321
+ }
322
+ function peoplePage(contextDir) {
323
+ const people = listPeople(contextDir);
324
+ const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis).length;
325
+ return {
326
+ title: 'People',
327
+ intro: 'Who I know, where to reach them, and how they like to be talked to. Each card is a summary of what\'s in memory, with a date on every fact.',
328
+ sections: [
329
+ { id: 'counts', blocks: [{ type: 'stats', items: [
330
+ { label: 'cards', value: String(people.length) },
331
+ { label: 'need a look', value: String(attention), tone: attention ? 'warn' : 'ok' },
332
+ ] }] },
333
+ {
334
+ id: 'everyone',
335
+ blocks: people.length
336
+ ? [{
337
+ type: 'list',
338
+ items: people.map((p) => {
339
+ const n = p.memories_since_synthesis;
340
+ const flag = [n ? `${n} new` : '', p.stale ? `${p.stale} stale` : '', p.unverified ? `${p.unverified} unverified` : ''].filter(Boolean).join(' · ');
341
+ return { text: p.name, meta: p.slug, badge: flag || undefined, tone: flag ? 'warn' : undefined, page: personPage(p.slug) };
342
+ }),
343
+ }]
344
+ : [{ type: 'text', text: 'No cards yet.', tone: 'muted' }],
345
+ },
346
+ ],
347
+ };
348
+ }
349
+ function personPageFor(contextDir, slug, tz, nowMs) {
350
+ let read;
351
+ try {
352
+ read = readPerson(contextDir, slug);
353
+ }
354
+ catch {
355
+ return { title: 'Not found', sections: [] };
356
+ }
357
+ // A piece can link to someone loom has no card for (it can't know which
358
+ // cards exist): say so, and show what memory has about them anyway.
359
+ if (!read.found || !('card' in read)) {
360
+ const about = memoriesAbout(contextDir, slug, { limit: 8, nowMs });
361
+ return {
362
+ title: slug,
363
+ sections: [
364
+ { id: 'none', blocks: [{ type: 'callout', text: `There's no person card for ${slug} yet.` }] },
365
+ ...(about.length ? [{ id: 'memories', title: 'What I remember about them', blocks: [{ type: 'list', items: about.map((m) => ({ text: m.title, meta: ago(m.created, tz, nowMs), page: memoryPage(m.ref) })) }] }] : []),
366
+ ],
367
+ };
368
+ }
369
+ const card = read.card;
370
+ const newer = memoriesAbout(contextDir, slug, { since: read.synthesized_at, nowMs });
371
+ const recent = memoriesAbout(contextDir, slug, { limit: 8, nowMs });
372
+ const sections = [];
373
+ if (newer.length) {
374
+ sections.push({
375
+ id: 'newer',
376
+ title: `${newer.length} memor${newer.length === 1 ? 'y is' : 'ies are'} newer than this card`,
377
+ tone: 'warn',
378
+ blocks: [{ type: 'list', items: newer.map((m) => ({ text: m.title, meta: ago(m.created, tz, nowMs), page: memoryPage(m.ref) })) }],
379
+ });
380
+ }
381
+ if (card.summary)
382
+ sections.push({ id: 'summary', blocks: [{ type: 'text', text: card.summary }] });
383
+ const where = Object.entries(card.where).sort(([a], [b]) => a.localeCompare(b));
384
+ if (where.length) {
385
+ sections.push({
386
+ id: 'where',
387
+ title: 'Where to reach them',
388
+ blocks: [{
389
+ type: 'table',
390
+ columns: ['where', 'value', 'from', 'checked'],
391
+ rows: where.map(([k, env]) => {
392
+ const s = fieldState(env, nowMs);
393
+ return [k, value(env.value), env.source ?? '', s.kind === 'ok' ? `ok, ${ago(env.verified_at ?? null, tz, nowMs)}` : s.kind === 'stale' ? `stale ${s.days}d` : 'unverified'];
394
+ }),
395
+ }],
396
+ });
397
+ }
398
+ const how = Object.entries(card.how);
399
+ if (how.length)
400
+ sections.push({ id: 'how', title: 'How to talk to them', side: true, blocks: [{ type: 'kv', items: how.map(([k, v]) => ({ label: k, value: value(v) })) }] });
401
+ if (card.may.length) {
402
+ sections.push({
403
+ id: 'may',
404
+ title: 'What they\'re cleared for',
405
+ side: true,
406
+ blocks: [
407
+ { type: 'list', items: card.may.map((m) => ({ text: m.text, meta: `said by ${m.source}` })) },
408
+ { type: 'text', text: 'A description of what was agreed. Actual access is set where it\'s enforced, not here.', tone: 'muted' },
409
+ ],
410
+ });
411
+ }
412
+ if (recent.length) {
413
+ sections.push({ id: 'memories', title: 'What I remember about them', blocks: [{ type: 'list', items: recent.map((m) => ({ text: m.title, meta: ago(m.created, tz, nowMs), page: memoryPage(m.ref) })) }] });
414
+ }
415
+ sections.push({ id: 'record', side: true, blocks: [{ type: 'kv', items: [
416
+ { label: 'slug', value: card.slug },
417
+ { label: 'card updated', value: when(read.updated, tz) },
418
+ { label: 'summary written', value: when(read.synthesized_at, tz) },
419
+ ] }] });
420
+ return { title: card.name, sections };
421
+ }
422
+ // ─── Routing and actions ─────────────────────────────────────────────────────
423
+ /** One of loom's pages by path (not Home), or null when there's no such page. */
424
+ export function corePage(contextDir, path, query, opts = {}) {
425
+ const tz = opts.timeZone ?? uiTimeZone();
426
+ const nowMs = opts.nowMs ?? Date.now();
427
+ if (path === 'mind')
428
+ return mindPage(contextDir, query, tz, nowMs);
429
+ if (path === 'people')
430
+ return peoplePage(contextDir);
431
+ if (path.startsWith('people/'))
432
+ return personPageFor(contextDir, path.slice('people/'.length), tz, nowMs);
433
+ if (path.startsWith('memory/'))
434
+ return memoryPageFor(contextDir, path.slice('memory/'.length), tz, nowMs);
435
+ return null;
436
+ }
437
+ /** Run one of loom's own actions from the page. */
438
+ export async function coreAct(contextDir, action, fields) {
439
+ if (action === 'archive') {
440
+ const ref = fields.ref ?? '';
441
+ if (!ref)
442
+ return { ok: false, message: 'No memory named.' };
443
+ const out = await archive(contextDir, { ref, note: 'archived from loom\'s page' });
444
+ return out.startsWith('Memory archived') ? { ok: true, message: 'Archived. It can be restored with memory_restore.', page: 'mind' } : { ok: false, message: out };
445
+ }
446
+ if (action === 'ratify' || action === 'reject') {
447
+ const id = Number.parseInt(fields.id ?? '', 10);
448
+ if (!Number.isSafeInteger(id) || id <= 0)
449
+ return { ok: false, message: 'No proposal named.' };
450
+ try {
451
+ if (action === 'reject') {
452
+ return rejectProposal(contextDir, id) ? { ok: true, message: `Proposal #${id} rejected.`, page: 'mind' } : { ok: false, message: `No proposal #${id}.` };
453
+ }
454
+ const ref = await ratifyProposal(contextDir, id);
455
+ return { ok: true, message: `Proposal #${id} is a memory now.`, page: memoryPage(ref.ref) ?? 'mind' };
456
+ }
457
+ catch (err) {
458
+ if (err instanceof UnknownProposalError)
459
+ return { ok: false, message: `No proposal #${id}.` };
460
+ return { ok: false, message: err.message };
461
+ }
462
+ }
463
+ return { ok: false, message: `loom has no action "${action}".` };
464
+ }
@@ -0,0 +1,53 @@
1
+ import { type Tier } from '../backends/salience.js';
2
+ export interface MemoryView {
3
+ ref: string;
4
+ title: string;
5
+ category: string;
6
+ project: string | null;
7
+ content: string;
8
+ sourcing: string | null;
9
+ scope: string | null;
10
+ created: string;
11
+ updated: string | null;
12
+ lastAccessed: string | null;
13
+ expiresAt: string | null;
14
+ timesSeen: number;
15
+ /** The person this memory is about (metadata.person), when tagged. */
16
+ person: string | null;
17
+ /** The stored field, refreshed by the consolidation lane. */
18
+ stored: number;
19
+ /** Heat right now (heatOf): what the digest uses. */
20
+ heat: number;
21
+ /** Its two parts: from being written or edited, and from being recalled. */
22
+ writtenHeat: number;
23
+ recallHeat: number;
24
+ tier: Tier;
25
+ halfLifeDays: number;
26
+ /** Whether it's in the boot digest: what the agent wakes up holding. */
27
+ inDigest: boolean;
28
+ }
29
+ /** Live memories (not episodes) hottest first, each marked with whether the digest carries it. */
30
+ export declare function heatList(contextDir: string, opts?: {
31
+ limit?: number;
32
+ category?: string;
33
+ nowMs?: number;
34
+ }): {
35
+ rows: MemoryView[];
36
+ total: number;
37
+ inDigest: number;
38
+ };
39
+ export declare function memoryByRef(contextDir: string, ref: string, nowMs?: number): MemoryView | null;
40
+ /** User memories tagged to a person, newest first. */
41
+ export declare function memoriesAbout(contextDir: string, slug: string, opts?: {
42
+ since?: string | null;
43
+ limit?: number;
44
+ nowMs?: number;
45
+ }): MemoryView[];
46
+ export interface TapeEntry {
47
+ ref: string;
48
+ title: string;
49
+ created: string;
50
+ where: string;
51
+ }
52
+ /** Episodes in the last `hours`, newest first, with where each body was. */
53
+ export declare function tapeRows(contextDir: string, hours: number, nowMs?: number): TapeEntry[];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Read-only queries behind loom's own pages: memories with their heat
3
+ * explained, one memory by ref, the memories about a person. Plain SQL over
4
+ * memories.db opened read-only, like the digest; nothing here writes.
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import BetterSqlite3 from 'better-sqlite3';
8
+ import { resolveSqliteDbPath } from '../config.js';
9
+ import { DEFAULT_HALF_LIFE_DAYS, HALF_LIVES, decayedRecallHeat, heatOf, pickForDigest, temperature, tierOf } from '../backends/salience.js';
10
+ const EPISODE = 'episode';
11
+ const COLUMNS = 'ref, title, category, project, content, sourcing, scope, created, updated, last_accessed, expires_at, times_seen, metadata, salience, recall_heat, recall_heat_at';
12
+ function open(contextDir) {
13
+ const path = resolveSqliteDbPath(contextDir);
14
+ if (!existsSync(path))
15
+ return null;
16
+ const db = new BetterSqlite3(path, { readonly: true });
17
+ db.pragma('busy_timeout = 5000');
18
+ return db;
19
+ }
20
+ function hasColumns(db) {
21
+ return new Set(db.pragma('table_info(memories)').map((c) => c.name));
22
+ }
23
+ /** Older stores lack some columns; select what exists and default the rest. */
24
+ function selectList(db) {
25
+ const have = hasColumns(db);
26
+ return COLUMNS.split(', ').map((c) => (have.has(c) ? c : `NULL AS ${c}`)).join(', ');
27
+ }
28
+ function personOf(metadata) {
29
+ try {
30
+ const m = JSON.parse(metadata || '{}');
31
+ return typeof m.person === 'string' && m.person ? m.person : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function view(r, nowMs, inDigest = false) {
38
+ const heat = heatOf(r, nowMs);
39
+ const writtenHeat = temperature(r.updated ?? r.created, r.category, nowMs);
40
+ const recallHeat = decayedRecallHeat(r.recall_heat, r.recall_heat_at, r.category, nowMs);
41
+ return {
42
+ ref: r.ref, title: r.title, category: r.category, project: r.project, content: r.content,
43
+ sourcing: r.sourcing, scope: r.scope, created: r.created, updated: r.updated,
44
+ lastAccessed: r.last_accessed, expiresAt: r.expires_at, timesSeen: r.times_seen ?? 1,
45
+ person: personOf(r.metadata), stored: r.salience ?? heat, heat, writtenHeat, recallHeat, tier: tierOf(heat),
46
+ halfLifeDays: HALF_LIVES[r.category] ?? DEFAULT_HALF_LIFE_DAYS, inDigest,
47
+ };
48
+ }
49
+ /** Live memories (not episodes) hottest first, each marked with whether the digest carries it. */
50
+ export function heatList(contextDir, opts = {}) {
51
+ const db = open(contextDir);
52
+ if (!db)
53
+ return { rows: [], total: 0, inDigest: 0 };
54
+ try {
55
+ const nowMs = opts.nowMs ?? Date.now();
56
+ const all = db.prepare(`SELECT ${selectList(db)} FROM memories WHERE archived = 0 AND category != ?`).all(EPISODE)
57
+ .map((r) => view(r, nowMs))
58
+ .sort((a, b) => b.heat - a.heat);
59
+ // The same selection the boot digest makes, so "in the digest" is exact.
60
+ const picked = new Set(pickForDigest(all.map((m) => ({ ...m, salience: m.heat }))).map((m) => m.ref));
61
+ for (const m of all)
62
+ m.inDigest = picked.has(m.ref);
63
+ const filtered = opts.category ? all.filter((m) => m.category === opts.category) : all;
64
+ return { rows: filtered.slice(0, opts.limit ?? 40), total: all.length, inDigest: picked.size };
65
+ }
66
+ finally {
67
+ db.close();
68
+ }
69
+ }
70
+ export function memoryByRef(contextDir, ref, nowMs = Date.now()) {
71
+ const db = open(contextDir);
72
+ if (!db)
73
+ return null;
74
+ try {
75
+ const r = db.prepare(`SELECT ${selectList(db)} FROM memories WHERE ref = ? AND archived = 0`).get(ref);
76
+ if (!r)
77
+ return null;
78
+ const m = view(r, nowMs);
79
+ if (m.category !== EPISODE) {
80
+ m.inDigest = heatList(contextDir, { limit: 400, nowMs }).rows.some((x) => x.ref === ref && x.inDigest);
81
+ }
82
+ return m;
83
+ }
84
+ finally {
85
+ db.close();
86
+ }
87
+ }
88
+ /** User memories tagged to a person, newest first. */
89
+ export function memoriesAbout(contextDir, slug, opts = {}) {
90
+ const db = open(contextDir);
91
+ if (!db)
92
+ return [];
93
+ try {
94
+ const nowMs = opts.nowMs ?? Date.now();
95
+ const rows = db.prepare(`SELECT ${selectList(db)} FROM memories
96
+ WHERE category = 'user' AND archived = 0 AND json_extract(metadata, '$.person') = ?
97
+ AND (expires_at IS NULL OR expires_at > ?) AND (? IS NULL OR created > ?)
98
+ ORDER BY created DESC LIMIT ?`).all(slug, new Date(nowMs).toISOString(), opts.since ?? null, opts.since ?? null, opts.limit ?? 20);
99
+ return rows.map((r) => view(r, nowMs));
100
+ }
101
+ finally {
102
+ db.close();
103
+ }
104
+ }
105
+ /** Episodes in the last `hours`, newest first, with where each body was. */
106
+ export function tapeRows(contextDir, hours, nowMs = Date.now()) {
107
+ const db = open(contextDir);
108
+ if (!db)
109
+ return [];
110
+ try {
111
+ const since = new Date(nowMs - hours * 3_600_000).toISOString();
112
+ const rows = db.prepare(`SELECT ref, title, created, project, metadata FROM memories
113
+ WHERE category = ? AND archived = 0 AND created >= ? AND (expires_at IS NULL OR expires_at > ?)
114
+ ORDER BY created DESC`).all(EPISODE, since, new Date(nowMs).toISOString());
115
+ return rows.map((r) => {
116
+ let where = r.project?.trim() || '?';
117
+ try {
118
+ const m = JSON.parse(r.metadata || '{}');
119
+ if (typeof m.where === 'string' && m.where.trim())
120
+ where = m.where.trim();
121
+ }
122
+ catch { /* keep the fallback */ }
123
+ return { ref: r.ref, title: r.title, created: r.created, where };
124
+ });
125
+ }
126
+ finally {
127
+ db.close();
128
+ }
129
+ }
@@ -0,0 +1,11 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export interface UiOptions {
3
+ contextDir: string;
4
+ /** Configured bearer secrets (the single token, or the token map's values). Empty = open. */
5
+ secrets: string[];
6
+ /** Env for piece discovery and piece processes (PATH decides which pieces exist). */
7
+ env: NodeJS.ProcessEnv;
8
+ }
9
+ export declare function isUiPath(pathname: string): boolean;
10
+ /** Handle a /ui request. The caller has already matched isUiPath. */
11
+ export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;