@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/dist/ui/core.js CHANGED
@@ -1,27 +1,22 @@
1
1
  /**
2
- * loom's own sections for the UI: what only loom knows. Read-only views over
3
- * the stores (top of mind, the episode tape, pending proposals, people),
4
- * assembled from the same functions the identity payload and the MCP-App
5
- * widgets use. Nothing here writes.
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.
6
7
  */
7
8
  import { readFileSync } from 'node:fs';
8
9
  import { join } from 'node:path';
9
- import { digestData } from '../backends/salience.js';
10
- import { episodeRows, tapeClock } from '../backends/episodes.js';
11
- import { listProposals } from '../backends/proposals.js';
12
- import { listPeople } from '../tools/person.js';
13
- const TOP_OF_MIND = 12;
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';
14
16
  const TAPE_HOURS = 24;
15
- const TAPE_SHOWN = 20;
16
- function whereOf(metadata, project) {
17
- try {
18
- const meta = JSON.parse(metadata || '{}');
19
- if (typeof meta.where === 'string' && meta.where.trim())
20
- return meta.where.trim();
21
- }
22
- catch { /* fall through */ }
23
- return project?.trim() || '?';
24
- }
17
+ const TAPE_MAX_HOURS = 24 * 7;
18
+ const MIND_SHOWN = 40;
19
+ const HOME_MIND = 5;
25
20
  /** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
26
21
  export function uiTimeZone() {
27
22
  for (const z of [process.env.LOOM_TIMEZONE, process.env.TZ, 'America/Chicago']) {
@@ -35,9 +30,6 @@ export function uiTimeZone() {
35
30
  }
36
31
  return 'UTC';
37
32
  }
38
- function tierTone(tier) {
39
- return tier === 'cold' ? 'muted' : undefined;
40
- }
41
33
  /** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
42
34
  export function agentName(contextDir) {
43
35
  try {
@@ -48,93 +40,425 @@ export function agentName(contextDir) {
48
40
  catch { /* no identity file */ }
49
41
  return contextDir.split('/').filter(Boolean).pop() ?? 'loom';
50
42
  }
51
- export function coreSections(contextDir, opts = {}) {
52
- const timeZone = opts.timeZone ?? uiTimeZone();
53
- const digest = digestData(contextDir, TOP_OF_MIND);
54
- const episodes = episodeRows(contextDir, { hours: TAPE_HOURS, nowMs: opts.nowMs });
55
- const pending = listProposals(contextDir).filter((p) => p.status === 'pending');
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 });
56
165
  const people = listPeople(contextDir);
166
+ const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis);
167
+ const sources = surfaces.length;
57
168
  const sections = [
58
- {
59
- id: 'overview',
60
- title: 'Overview',
61
- blocks: [{
62
- type: 'stats',
63
- items: [
64
- { label: 'memories', value: String(digest?.total ?? 0) },
65
- { label: `episodes, last ${TAPE_HOURS}h`, value: String(episodes.length) },
66
- { label: 'proposals pending', value: String(pending.length), tone: pending.length ? 'warn' : undefined },
67
- { label: 'people', value: String(people.length) },
68
- ],
69
- }],
70
- },
71
169
  {
72
170
  id: 'tape',
73
- title: `Last ${TAPE_HOURS}h`,
74
- blocks: episodes.length
75
- ? [
76
- {
77
- type: 'list',
78
- items: [...episodes].reverse().slice(0, TAPE_SHOWN).map((e) => ({
79
- text: e.title,
80
- meta: `${tapeClock(e.created, timeZone)} · ${whereOf(e.metadata, e.project)}`,
81
- })),
82
- },
83
- ...(episodes.length > TAPE_SHOWN
84
- ? [{ type: 'text', text: `and ${episodes.length - TAPE_SHOWN} earlier` }]
85
- : []),
86
- ]
87
- : [{ type: 'text', text: 'No episodes in the last day.' }],
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
+ ],
88
176
  },
89
177
  {
90
- id: 'top-of-mind',
91
- title: 'Top of mind',
92
- blocks: digest && digest.atoms.length
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
93
193
  ? [{
94
194
  type: 'list',
95
- items: digest.atoms.map((a) => ({ text: a.title, meta: `${a.category} · ${a.tier}`, tone: tierTone(a.tier) })),
96
- }]
97
- : [{ type: 'text', text: 'Nothing hot yet.' }],
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',
98
216
  },
99
- ];
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 = [];
100
230
  if (pending.length) {
101
231
  sections.push({
102
232
  id: 'proposals',
103
- title: 'Proposals waiting',
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',
104
388
  blocks: [{
105
- type: 'list',
106
- items: pending.map((p) => ({
107
- text: p.title ?? '(untitled)',
108
- meta: `#${p.id} · ${p.category ?? '?'}${p.source ? ` · ${p.source}` : ''}`,
109
- tone: 'warn',
110
- })),
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
+ }),
111
395
  }],
112
396
  });
113
397
  }
114
- if (people.length) {
115
- // Only the cards that need a look: stale or unverified fields, or memories
116
- // written since the summary was last synthesized.
117
- const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis);
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) {
118
402
  sections.push({
119
- id: 'people',
120
- title: 'People',
403
+ id: 'may',
404
+ title: 'What they\'re cleared for',
405
+ side: true,
121
406
  blocks: [
122
- {
123
- type: 'stats',
124
- items: [
125
- { label: 'cards', value: String(people.length) },
126
- { label: 'need a look', value: String(attention.length), tone: attention.length ? 'warn' : 'ok' },
127
- ],
128
- },
129
- attention.length
130
- ? {
131
- type: 'table',
132
- columns: ['name', 'stale', 'unverified', 'new memories'],
133
- rows: attention.map((p) => [p.name, String(p.stale), String(p.unverified), String(p.memories_since_synthesis)]),
134
- }
135
- : { type: 'text', text: 'Every card is fresh.' },
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' },
136
409
  ],
137
410
  });
138
411
  }
139
- return sections;
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}".` };
140
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[];