@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.
- package/CHANGELOG.md +61 -1
- package/README.md +36 -2
- package/dist/backends/migrations.js +18 -0
- package/dist/backends/salience.d.ts +38 -4
- package/dist/backends/salience.js +82 -30
- package/dist/backends/sqlite-vec.js +14 -5
- package/dist/transport/http-server.js +14 -0
- package/dist/ui/core.d.ts +21 -0
- package/dist/ui/core.js +464 -0
- package/dist/ui/data.d.ts +53 -0
- package/dist/ui/data.js +129 -0
- package/dist/ui/handler.d.ts +11 -0
- package/dist/ui/handler.js +399 -0
- package/dist/ui/pieces.d.ts +37 -0
- package/dist/ui/pieces.js +191 -0
- package/dist/ui/render.d.ts +53 -0
- package/dist/ui/render.js +242 -0
- package/dist/ui/vocab.d.ts +175 -0
- package/dist/ui/vocab.js +360 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,64 @@ 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
|
+
|
|
54
|
+
## [0.8.0] - 2026-10-24
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **loom's page: `/ui` on the HTTP daemon.** A read-only web page with loom's
|
|
59
|
+
own sections: top of mind, the last 24h of episodes, proposals waiting, and
|
|
60
|
+
people. It also gets a group for each installed piece that answers
|
|
61
|
+
`loom-<piece> ui --json` in a small section vocabulary (stats, list, table,
|
|
62
|
+
text, link). Pieces send data, never markup: everything is escaped, the page
|
|
63
|
+
has no script, and a CSP forbids script and framing. Invalid answers are
|
|
64
|
+
dropped whole, and a slow or missing piece is left out.
|
|
65
|
+
- **Login:** the bearer token, once. The page requires `LOOM_BEARER_TOKEN` (no
|
|
66
|
+
token gives 403, like `/cli`). The cookie is keyed per daemon process, isn't
|
|
67
|
+
the token, and lasts 30 days or until a restart. Pieces never see loom's
|
|
68
|
+
tokens; on a timeout their whole process group is killed; concurrent loads
|
|
69
|
+
share one run per piece.
|
|
70
|
+
- **`/ui/sections.json`:** the same data, for scripts.
|
|
71
|
+
- **Scope:** full scope only.
|
|
72
|
+
|
|
15
73
|
## [0.7.0] - 2026-10-24
|
|
16
74
|
|
|
17
75
|
### Added
|
|
@@ -588,7 +646,9 @@ Initial public release.
|
|
|
588
646
|
stack and all added external-service dependencies or operational
|
|
589
647
|
overhead.
|
|
590
648
|
|
|
591
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
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
|
|
651
|
+
[0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
|
|
592
652
|
[0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
|
|
593
653
|
[0.6.0]: https://github.com/sleepunit-agents/loom/compare/v0.5.0...v0.6.0
|
|
594
654
|
[0.5.0]: https://github.com/sleepunit-agents/loom/compare/v0.4.1...v0.5.0
|
package/README.md
CHANGED
|
@@ -383,6 +383,36 @@ header, or a body that isn't `application/json`). The knowledge-only daemon
|
|
|
383
383
|
has no `/cli`. Every other command (`doctor`, `inject`, `serve`, …) stays
|
|
384
384
|
local, and `--url` on one of them is an error.
|
|
385
385
|
|
|
386
|
+
### loom's page — `/ui`
|
|
387
|
+
|
|
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").
|
|
401
|
+
|
|
402
|
+
**The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
|
|
403
|
+
(403), the same rule as `/cli`, because any site that DNS-rebinds a name to
|
|
404
|
+
the loopback address could read an open page. Log in once with the token. The
|
|
405
|
+
browser gets a session: a random id the daemon keeps in memory. It's not the
|
|
406
|
+
token, it can't be replayed against `/mcp` or `/cli`, and it can't be used to
|
|
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.
|
|
415
|
+
|
|
386
416
|
### Behind a reverse proxy
|
|
387
417
|
|
|
388
418
|
Terminate TLS at a reverse proxy in front of the daemon and point the
|
|
@@ -476,8 +506,12 @@ wakes knowing what's top-of-mind without having to fish via `recall`.
|
|
|
476
506
|
Each memory carries a stored salience "temperature" that **decays by a
|
|
477
507
|
per-category half-life** (pursuit coolest-fastest at 7 days, through
|
|
478
508
|
project, self/feedback, reference, up to user at 90 days) and **reheats
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
481
515
|
selected memories into tiers (Hot / Warm / Cool, labeled *Top of mind* /
|
|
482
516
|
*Recent* / *Background*).
|
|
483
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
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
141
|
-
if (used +
|
|
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(
|
|
144
|
-
used +=
|
|
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
|
|
202
|
-
.
|
|
203
|
-
.
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
}
|
|
@@ -32,6 +32,7 @@ import { EmptyResultSchema } from '@modelcontextprotocol/core';
|
|
|
32
32
|
import { createLoomServer } from '../server.js';
|
|
33
33
|
import { createBackend } from '../backends/index.js';
|
|
34
34
|
import { handleCliBody } from './cli-endpoint.js';
|
|
35
|
+
import { handleUi, isUiPath } from '../ui/handler.js';
|
|
35
36
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
36
37
|
/**
|
|
37
38
|
* SSE keep-alive: the SDK opens the standalone GET stream but sends no
|
|
@@ -196,6 +197,19 @@ export async function startHttpServer(opts) {
|
|
|
196
197
|
}
|
|
197
198
|
const http = createServer((req, res) => {
|
|
198
199
|
void (async () => {
|
|
200
|
+
// /ui: loom's web page (src/ui). Its own auth (a cookie from the bearer,
|
|
201
|
+
// or the bearer header), so it comes before the bearer gate. Full scope
|
|
202
|
+
// only: a scoped server has no memory to show.
|
|
203
|
+
const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
204
|
+
if (isUiPath(uiPath)) {
|
|
205
|
+
if (opts.createServer)
|
|
206
|
+
return sendError(res, 404, 'not-found: this server has no UI');
|
|
207
|
+
return handleUi(req, res, uiPath, {
|
|
208
|
+
contextDir: opts.contextDir,
|
|
209
|
+
secrets: opts.token ? [opts.token] : Object.values(opts.tokens ?? {}),
|
|
210
|
+
env: process.env,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
199
213
|
// Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
|
|
200
214
|
// token map) is configured. Multi-token mode additionally resolves
|
|
201
215
|
// WHICH identity made this call — the bearer itself is the identity
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ActResult, type Page, type Tile } from './vocab.js';
|
|
2
|
+
/** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
|
|
3
|
+
export declare function uiTimeZone(): string;
|
|
4
|
+
/** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
|
|
5
|
+
export declare function agentName(contextDir: string): string;
|
|
6
|
+
export interface CoreOptions {
|
|
7
|
+
timeZone?: string;
|
|
8
|
+
nowMs?: number;
|
|
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>;
|