@jbarket/loomai 0.9.2 → 0.11.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,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.11.0] - 2026-10-24
16
+
17
+ ### Changed
18
+
19
+ - **The household, phase 2b: one directory entry per person, a relationship
20
+ per agent.** In a household a person card is kept in two places:
21
+ - **The directory** (`commons/people.db`): name, handles (`where`), and
22
+ the facts in `how` (`tz`, `timezone`, `pronouns`, `languages`), shared
23
+ by every agent.
24
+ - **Each agent's relationship** (its own `memories.db`): the summary, the
25
+ rest of `how` (how *this* agent talks to them) and `may`.
26
+
27
+ How it behaves:
28
+ - A read merges the two halves into one card, rendered exactly as before.
29
+ - A write splits by field. Both halves are rehearsed in memory first, so
30
+ a write either half would refuse changes neither.
31
+ - Revisions record the agent that made them (`person_revisions.actor`),
32
+ and `person_history` says which half each revision came from.
33
+ - "Memories newer than the card" counts each agent's own memories.
34
+
35
+ Moving in:
36
+ - `loom household commons` and `init` split an existing agent's cards,
37
+ checking first that every card still renders the same text. Running it
38
+ again is safe.
39
+ - A household daemon refuses to start with cards that haven't been split.
40
+ - **The household, phase 2a: knowledge is the commons.**
41
+ - In a household, every agent reads and writes `commons/knowledge.db`.
42
+ - `loom household init` moves the first agent's knowledge there.
43
+ - `loom household commons` moves it for a household made before this
44
+ release. It checkpoints the WAL first, and must run with every daemon
45
+ stopped.
46
+ - A household daemon refuses to start while an agent still has its own
47
+ `knowledge.db`, so the knowledge base can't quietly fork.
48
+ - A plain context dir keeps its own knowledge, as before.
49
+
50
+ ## [0.10.0] - 2026-10-24
51
+
52
+ ### Security
53
+
54
+ - **`harness_init` could write any file the daemon's user can.** Its
55
+ `target` was a free path and the harness name could carry newlines, so any
56
+ MCP caller could append to another agent's `IDENTITY.md` or plant a command
57
+ in a shell rc file. Over HTTP the target is now refused: it names a file on
58
+ the server, never the caller's. Locally it must be a harness instruction
59
+ file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, …), judged at its real path.
60
+ Names refuse control characters, and managed-block writes use a random,
61
+ exclusive temp file. Found by the security review of the household work.
62
+
63
+ ### Added
64
+
65
+ - **The household, phase 1: one loom, several agents**
66
+ (docs/specs/household.md).
67
+ - `$LOOM_HOME/household.json` lists the agents and the default, and
68
+ `tokens.json` holds every token by sha256 with a label and a role.
69
+ - **Roles:** `agent:<name>` is MCP and `/cli` as that agent's stack only.
70
+ `owner` gets the default agent, another at `/agents/<name>/`, and the
71
+ page. `peer:<name>` gets knowledge tools only.
72
+ - **On every request** the daemon resolves the role and builds the
73
+ session's server over that agent's context dir. A session stays pinned
74
+ to the role and agent it opened with, and a revoke takes effect on the
75
+ next request.
76
+ - **New commands:** `loom household init|add|show` and
77
+ `loom token create|list|revoke`.
78
+ - **Nothing changes for one agent:** a plain context dir is a household of
79
+ one and behaves exactly as before.
80
+
15
81
  ## [0.9.2] - 2026-10-24
16
82
 
17
83
  ### Fixed
@@ -666,7 +732,9 @@ Initial public release.
666
732
  stack and all added external-service dependencies or operational
667
733
  overhead.
668
734
 
669
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...HEAD
735
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...HEAD
736
+ [0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
737
+ [0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
670
738
  [0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
671
739
  [0.9.1]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...v0.9.1
672
740
  [0.9.0]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...v0.9.0
package/README.md CHANGED
@@ -648,6 +648,35 @@ loom tasks ready # → loom-tasks ready
648
648
  The rules, the dispatch details and how pieces compose:
649
649
  [`docs/extensions.md`](docs/extensions.md).
650
650
 
651
+ ### The household — several agents on one loom
652
+
653
+ One loom can serve several agents, with a shared commons
654
+ ([`docs/specs/household.md`](docs/specs/household.md)). A household is a
655
+ directory (`$LOOM_HOME`, default `~/.config/loom`) with a `household.json`,
656
+ a `tokens.json`, and one context dir per agent under `agents/`. A plain
657
+ context dir with no household around it is a household of one and works
658
+ exactly as before.
659
+
660
+ ```bash
661
+ loom household init art # ~/.config/loom/art → agents/art (a symlink stays)
662
+ loom household add mark # an agent whose dir already exists under agents/
663
+ loom household commons # once, daemons stopped: knowledge.db → commons/ (shared by every agent)
664
+ loom token create --agent art --label "Art · house on wunder" # printed once
665
+ loom token create --owner --label "Jonathan · page"
666
+ loom token list
667
+ loom token revoke "Art · house on wunder"
668
+ ```
669
+
670
+ Every token has a role. **`agent:<name>`** is MCP and `/cli` as that agent:
671
+ its own stack only. **`owner`** gets the default agent, or another at
672
+ `/agents/<name>/` on the daemon, and the page. **`peer:<name>`** gets
673
+ knowledge tools only. The daemon resolves the role on every request; no tool
674
+ takes an agent argument, so a body can only be the agent its token names.
675
+ `LOOM_BEARER_TOKEN`, when set, is an owner token too. A daemon whose context
676
+ dir is `<home>/agents/<name>` next to a `household.json` serves the whole
677
+ household. A process with `LOOM_HOME` set and no context dir uses
678
+ `LOOM_AGENT`, or the household's default.
679
+
651
680
  ### Harness manifests and self-describe
652
681
 
653
682
  A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
@@ -337,6 +337,15 @@ export const MIGRATIONS = [
337
337
  }).immediate();
338
338
  },
339
339
  },
340
+ {
341
+ id: 'person_revisions_actor',
342
+ description: 'Add person_revisions.actor: which agent made the change a revision records, so a ' +
343
+ 'shared directory entry shows who changed what (docs/specs/household.md)',
344
+ pending: (db) => hasTable(db, 'person_revisions') && !hasColumn(db, 'person_revisions', 'actor'),
345
+ run: (db) => {
346
+ db.prepare('ALTER TABLE person_revisions ADD COLUMN actor TEXT').run();
347
+ },
348
+ },
340
349
  ];
341
350
  /**
342
351
  * Apply pending migrations to a database.
@@ -0,0 +1,77 @@
1
+ import { PeopleStore, type MutationResult, type PersonCard, type PersonListEntry, type PersonReadResult, type PersonRevisionMeta, type PersonRow, type PersonWriteInput } from './people.js';
2
+ import { type Household } from '../household.js';
3
+ /** `how` fields that are facts about the person, not how one agent talks to them. */
4
+ export declare const FACT_KEYS: ReadonlySet<string>;
5
+ export declare function commonsPeoplePath(h: Household): string;
6
+ /** The directory half and the relationship half of a whole card. */
7
+ export declare function splitCard(card: PersonCard): {
8
+ directory: PersonCard;
9
+ relationship: PersonCard;
10
+ };
11
+ /** One card from its two halves. */
12
+ export declare function mergeCard(d: PersonCard, r: PersonCard | null): PersonCard;
13
+ export declare class HouseholdPeople {
14
+ private readonly directory;
15
+ private readonly relationship;
16
+ private readonly now;
17
+ constructor(directory: PeopleStore, relationship: PeopleStore, now?: () => number);
18
+ close(): void;
19
+ getRow(slug: string): PersonRow | null;
20
+ read(slug: string, includeArchived?: boolean): PersonReadResult;
21
+ list(includeArchived?: boolean): PersonListEntry[];
22
+ /**
23
+ * Split a write by field: name, `where` and the fact keys of `how` go to the
24
+ * directory; summary, the rest of `how` and `may` to this agent's
25
+ * relationship. Both halves are rehearsed against in-memory copies first,
26
+ * so a write either half would refuse changes neither.
27
+ */
28
+ write(input: PersonWriteInput): MutationResult;
29
+ verify(input: {
30
+ slug: string;
31
+ fields: string[];
32
+ verified_at?: string;
33
+ source?: string;
34
+ }): MutationResult;
35
+ archive(input: {
36
+ slug: string;
37
+ reason: string;
38
+ actor: string;
39
+ }): MutationResult;
40
+ restore(input: {
41
+ slug: string;
42
+ }): MutationResult;
43
+ /** Revisions from both halves, newest first, each saying which half and which agent. */
44
+ history(slug: string): PersonRevisionMeta[];
45
+ /**
46
+ * Restore one half to a revision: `store` says which (the relationship,
47
+ * unless named). A relationship revision from before the split holds the
48
+ * whole old card; only its relationship half comes back, so handles and
49
+ * facts never return to the agent's own store.
50
+ */
51
+ revisionRestore(input: {
52
+ slug: string;
53
+ revision_id: number;
54
+ store?: 'directory' | 'relationship';
55
+ }): MutationResult;
56
+ private mutation;
57
+ }
58
+ /**
59
+ * Open the people for a household agent: the commons directory plus the
60
+ * agent's relationships, both attributing revisions to the agent. A
61
+ * read-only open returns null when the directory doesn't exist yet.
62
+ */
63
+ export declare function openHouseholdPeople(h: Household, agent: string, contextDir: string, opts?: {
64
+ readonly?: boolean;
65
+ now?: () => number;
66
+ }): HouseholdPeople | null;
67
+ /**
68
+ * Move an agent's whole cards into the household split: the directory half
69
+ * to the commons, the relationship half back in place (timestamps and
70
+ * archive state kept). Every card is checked first: merged back together it
71
+ * must render the same text it does today, or nothing is written. Refuses a
72
+ * slug the directory already has (merging a second agent's people is a later
73
+ * step). Returns how many cards moved.
74
+ */
75
+ export declare function splitPeopleIntoCommons(h: Household, agent: string, contextDir: string, nowMs?: number): number;
76
+ /** Whether an agent still holds directory fields (handles, facts) in its own cards: an unsplit store. */
77
+ export declare function hasUnsplitPeople(contextDir: string): boolean;
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Person cards in a household (docs/specs/household.md, "The commons"): one
3
+ * card a reader sees, kept in two places.
4
+ *
5
+ * directory commons/people.db, one entry per human, shared by every
6
+ * agent: name, `where` (handles), and the facts in `how`
7
+ * that don't depend on who's asking (FACT_KEYS)
8
+ * relationship the agent's own memories.db: summary, the rest of `how`
9
+ * (how *this* agent talks to them), `may`
10
+ *
11
+ * A read merges the two into one card, rendered exactly as before. A write
12
+ * splits by field. Every revision names the agent that made it. No agent
13
+ * reads another's relationship: there's no API for it here.
14
+ *
15
+ * Same surface as PeopleStore, so the person tools don't care which they got.
16
+ */
17
+ import { join } from 'node:path';
18
+ import { existsSync, mkdirSync } from 'node:fs';
19
+ import { PeopleStore, PersonCardError, renderCard, } from './people.js';
20
+ import { resolveSqliteDbPath } from '../config.js';
21
+ import { commonsDir } from '../household.js';
22
+ /** `how` fields that are facts about the person, not how one agent talks to them. */
23
+ export const FACT_KEYS = new Set(['tz', 'timezone', 'pronouns', 'languages']);
24
+ export function commonsPeoplePath(h) {
25
+ return join(commonsDir(h), 'people.db');
26
+ }
27
+ function canonicalJson(v) {
28
+ if (Array.isArray(v))
29
+ return `[${v.map(canonicalJson).join(',')}]`;
30
+ if (v && typeof v === 'object')
31
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
32
+ return JSON.stringify(v);
33
+ }
34
+ function splitHow(how) {
35
+ const facts = {};
36
+ const rest = {};
37
+ for (const [k, v] of Object.entries(how ?? {}))
38
+ (FACT_KEYS.has(k) ? facts : rest)[k] = v;
39
+ return { facts, rest };
40
+ }
41
+ /** The directory half and the relationship half of a whole card. */
42
+ export function splitCard(card) {
43
+ const { facts, rest } = splitHow(card.how);
44
+ return {
45
+ directory: { slug: card.slug, name: card.name, where: card.where ?? {}, how: facts, may: [] },
46
+ relationship: {
47
+ slug: card.slug, name: card.name, ...(card.summary !== undefined ? { summary: card.summary } : {}),
48
+ where: {}, how: rest, may: card.may ?? [],
49
+ },
50
+ };
51
+ }
52
+ /** One card from its two halves. */
53
+ export function mergeCard(d, r) {
54
+ return {
55
+ slug: d.slug,
56
+ name: d.name,
57
+ ...(r?.summary !== undefined ? { summary: r.summary } : {}),
58
+ where: d.where ?? {},
59
+ how: { ...(r?.how ?? {}), ...(d.how ?? {}) },
60
+ may: r?.may ?? [],
61
+ };
62
+ }
63
+ /** Try a write against an in-memory copy of the current row; throws exactly what the real write would. */
64
+ function rehearse(row, input, now) {
65
+ if (!input)
66
+ return;
67
+ const mem = new PeopleStore({ dbPath: ':memory:', now });
68
+ try {
69
+ if (row) {
70
+ mem.replaceCard(row.slug, row.card, {
71
+ updated: row.updated, synthesized_at: row.synthesized_at,
72
+ archived_at: row.archived_at, archived_by: row.archived_by, archive_reason: row.archive_reason,
73
+ });
74
+ }
75
+ mem.write(input);
76
+ }
77
+ finally {
78
+ mem.close();
79
+ }
80
+ }
81
+ export class HouseholdPeople {
82
+ directory;
83
+ relationship;
84
+ now;
85
+ constructor(directory, relationship, now = Date.now) {
86
+ this.directory = directory;
87
+ this.relationship = relationship;
88
+ this.now = now;
89
+ }
90
+ close() {
91
+ this.directory.close();
92
+ this.relationship.close();
93
+ }
94
+ getRow(slug) {
95
+ const d = this.directory.getRow(slug);
96
+ if (!d)
97
+ return null;
98
+ const r = this.relationship.getRow(slug);
99
+ return {
100
+ ...d,
101
+ card: mergeCard(d.card, r?.card ?? null),
102
+ updated: r && r.updated > d.updated ? r.updated : d.updated,
103
+ synthesized_at: r?.synthesized_at ?? null,
104
+ };
105
+ }
106
+ read(slug, includeArchived = false) {
107
+ const row = this.getRow(slug);
108
+ if (!row)
109
+ return this.directory.read(slug, includeArchived);
110
+ const archived = row.archived_at !== null;
111
+ if (archived && !includeArchived)
112
+ return { found: true, archived: true };
113
+ return {
114
+ found: true,
115
+ rendered: renderCard(row.card, this.now()) + (archived ? `\n_(archived ${row.archived_at} by ${row.archived_by}: ${row.archive_reason})_` : ''),
116
+ card: row.card,
117
+ archived,
118
+ updated: row.updated,
119
+ synthesized_at: row.synthesized_at,
120
+ };
121
+ }
122
+ list(includeArchived = false) {
123
+ const rel = new Map(this.relationship.list(true).map((e) => [e.slug, e]));
124
+ return this.directory.list(includeArchived).map((e) => {
125
+ const r = rel.get(e.slug);
126
+ const since = r?.synthesized_at ?? null;
127
+ return {
128
+ ...e,
129
+ updated: r && r.updated > e.updated ? r.updated : e.updated,
130
+ synthesized_at: since,
131
+ memories_since_synthesis: this.relationship.memoriesSince(e.slug, since),
132
+ };
133
+ });
134
+ }
135
+ /**
136
+ * Split a write by field: name, `where` and the fact keys of `how` go to the
137
+ * directory; summary, the rest of `how` and `may` to this agent's
138
+ * relationship. Both halves are rehearsed against in-memory copies first,
139
+ * so a write either half would refuse changes neither.
140
+ */
141
+ write(input) {
142
+ const merge = input.merge ?? true;
143
+ const dirRow = this.directory.getRow(input.slug);
144
+ const relRow = this.relationship.getRow(input.slug);
145
+ const name = input.name ?? dirRow?.card.name;
146
+ if (name === undefined)
147
+ throw new PersonCardError('name: is required to create a card');
148
+ const { facts, rest } = splitHow(input.how);
149
+ const dirInput = input.name !== undefined || input.where !== undefined || Object.keys(facts).length > 0 || !dirRow || !merge
150
+ ? { slug: input.slug, name, merge, ...(input.where !== undefined ? { where: input.where } : {}), how: facts }
151
+ : null;
152
+ const relInput = input.summary !== undefined || Object.keys(rest).length > 0 || input.may !== undefined || !merge || !relRow || (input.name !== undefined && relRow.card.name !== input.name)
153
+ ? {
154
+ slug: input.slug, name, merge,
155
+ ...(input.summary !== undefined ? { summary: input.summary } : {}),
156
+ how: rest,
157
+ ...(input.may !== undefined ? { may: input.may } : {}),
158
+ }
159
+ : null;
160
+ rehearse(dirRow, dirInput, this.now);
161
+ rehearse(relRow, relInput, this.now);
162
+ if (dirInput)
163
+ this.directory.write(dirInput);
164
+ if (relInput)
165
+ this.relationship.write(relInput);
166
+ return this.mutation(input.slug);
167
+ }
168
+ verify(input) {
169
+ this.directory.verify(input);
170
+ return this.mutation(input.slug);
171
+ }
172
+ archive(input) {
173
+ this.directory.archive(input);
174
+ if (this.relationship.getRow(input.slug))
175
+ this.relationship.archive(input);
176
+ return this.mutation(input.slug);
177
+ }
178
+ restore(input) {
179
+ this.directory.restore(input);
180
+ if (this.relationship.getRow(input.slug))
181
+ this.relationship.restore(input);
182
+ return this.mutation(input.slug);
183
+ }
184
+ /** Revisions from both halves, newest first, each saying which half and which agent. */
185
+ history(slug) {
186
+ return [
187
+ ...this.directory.history(slug).map((r) => ({ ...r, store: 'directory' })),
188
+ ...this.relationship.history(slug).map((r) => ({ ...r, store: 'relationship' })),
189
+ ].sort((a, b) => (a.replaced_at < b.replaced_at ? 1 : a.replaced_at > b.replaced_at ? -1 : 0));
190
+ }
191
+ /**
192
+ * Restore one half to a revision: `store` says which (the relationship,
193
+ * unless named). A relationship revision from before the split holds the
194
+ * whole old card; only its relationship half comes back, so handles and
195
+ * facts never return to the agent's own store.
196
+ */
197
+ revisionRestore(input) {
198
+ if (input.store === 'directory') {
199
+ this.directory.revisionRestore(input);
200
+ return this.mutation(input.slug);
201
+ }
202
+ const snap = this.relationship.revisionSnapshot(input.revision_id);
203
+ if (!snap)
204
+ throw new PersonCardError(`revision ${input.revision_id} not found`);
205
+ if (snap.slug !== input.slug)
206
+ throw new PersonCardError(`revision ${input.revision_id} belongs to another card, not ${input.slug}`);
207
+ if (!holdsDirectoryFields(snap.card)) {
208
+ this.relationship.revisionRestore(input);
209
+ return this.mutation(input.slug);
210
+ }
211
+ // The same gate the plain restore path has: no restoring onto an archived card.
212
+ const current = this.relationship.getRow(input.slug);
213
+ if (!current)
214
+ throw new PersonCardError(`no card for ${input.slug}`);
215
+ const dirRow = this.directory.getRow(input.slug);
216
+ if (!dirRow)
217
+ throw new PersonCardError(`no card for ${input.slug}`);
218
+ if (current.archived_at !== null || dirRow.archived_at !== null) {
219
+ throw new PersonCardError(`card for ${input.slug} is archived; person_restore it before restoring a revision`);
220
+ }
221
+ this.relationship.replaceCard(input.slug, splitCard(snap.card).relationship, {
222
+ updated: new Date(this.now()).toISOString(), synthesized_at: snap.synthesized_at ?? null,
223
+ archived_at: current.archived_at, archived_by: current.archived_by, archive_reason: current.archive_reason,
224
+ });
225
+ return this.mutation(input.slug);
226
+ }
227
+ mutation(slug) {
228
+ const row = this.getRow(slug);
229
+ if (!row)
230
+ throw new PersonCardError(`no card for ${slug}`);
231
+ return { card: row.card, unchanged: false, revision_id: null, archived: row.archived_at !== null, updated: row.updated, synthesized_at: row.synthesized_at };
232
+ }
233
+ }
234
+ /**
235
+ * Open the people for a household agent: the commons directory plus the
236
+ * agent's relationships, both attributing revisions to the agent. A
237
+ * read-only open returns null when the directory doesn't exist yet.
238
+ */
239
+ export function openHouseholdPeople(h, agent, contextDir, opts = {}) {
240
+ const dirPath = commonsPeoplePath(h);
241
+ const relPath = resolveSqliteDbPath(contextDir);
242
+ if (opts.readonly && (!existsSync(dirPath) || !existsSync(relPath)))
243
+ return null;
244
+ if (!opts.readonly)
245
+ mkdirSync(commonsDir(h), { recursive: true, mode: 0o700 });
246
+ return new HouseholdPeople(new PeopleStore({ dbPath: dirPath, readonly: opts.readonly, now: opts.now, actor: agent }), new PeopleStore({ dbPath: relPath, readonly: opts.readonly, now: opts.now, actor: agent }), opts.now);
247
+ }
248
+ /**
249
+ * Move an agent's whole cards into the household split: the directory half
250
+ * to the commons, the relationship half back in place (timestamps and
251
+ * archive state kept). Every card is checked first: merged back together it
252
+ * must render the same text it does today, or nothing is written. Refuses a
253
+ * slug the directory already has (merging a second agent's people is a later
254
+ * step). Returns how many cards moved.
255
+ */
256
+ export function splitPeopleIntoCommons(h, agent, contextDir, nowMs = Date.now()) {
257
+ if (!existsSync(resolveSqliteDbPath(contextDir)))
258
+ return 0;
259
+ mkdirSync(commonsDir(h), { recursive: true, mode: 0o700 });
260
+ const rel = new PeopleStore({ dbPath: resolveSqliteDbPath(contextDir), actor: agent });
261
+ const dir = new PeopleStore({ dbPath: commonsPeoplePath(h), actor: agent });
262
+ try {
263
+ const plan = rel.rows().flatMap((r) => {
264
+ const halves = splitCard(r.card);
265
+ const there = dir.getRow(r.slug);
266
+ // Already split (a re-run): the agent's card holds no handles or facts
267
+ // and the directory has the person.
268
+ if (there && !holdsDirectoryFields(r.card))
269
+ return [];
270
+ if (there && canonicalJson(there.card) !== canonicalJson(halves.directory)) {
271
+ throw new PersonCardError(`${r.slug} is already in the directory with different details; merging another agent's card into it isn't automatic yet`);
272
+ }
273
+ const before = renderCard(r.card, nowMs);
274
+ const after = renderCard(mergeCard(halves.directory, halves.relationship), nowMs);
275
+ if (before !== after)
276
+ throw new PersonCardError(`${r.slug} would render differently after the split; nothing was moved`);
277
+ return [{ r, halves }];
278
+ });
279
+ for (const { r, halves } of plan) {
280
+ const keep = { updated: r.updated, archived_at: r.archived_at, archived_by: r.archived_by, archive_reason: r.archive_reason };
281
+ dir.replaceCard(r.slug, halves.directory, { ...keep, synthesized_at: null });
282
+ rel.replaceCard(r.slug, halves.relationship, { ...keep, synthesized_at: r.synthesized_at });
283
+ }
284
+ return plan.length;
285
+ }
286
+ finally {
287
+ rel.close();
288
+ dir.close();
289
+ }
290
+ }
291
+ function holdsDirectoryFields(card) {
292
+ return Object.keys(card.where ?? {}).length > 0 || Object.keys(splitHow(card.how).facts).length > 0;
293
+ }
294
+ /** Whether an agent still holds directory fields (handles, facts) in its own cards: an unsplit store. */
295
+ export function hasUnsplitPeople(contextDir) {
296
+ if (!existsSync(resolveSqliteDbPath(contextDir)))
297
+ return false;
298
+ const rel = new PeopleStore({ dbPath: resolveSqliteDbPath(contextDir), readonly: true });
299
+ try {
300
+ return rel.rows().some((r) => holdsDirectoryFields(r.card));
301
+ }
302
+ finally {
303
+ rel.close();
304
+ }
305
+ }
@@ -79,8 +79,12 @@ export interface PersonRevisionMeta {
79
79
  id: number;
80
80
  op: RevisionOp;
81
81
  replaced_at: string;
82
+ /** The agent whose change this revision records (null before households). */
83
+ actor?: string | null;
84
+ /** In a household: which half of the card this revision is from. */
85
+ store?: 'directory' | 'relationship';
82
86
  }
83
- export type RevisionOp = 'write' | 'verify' | 'archive' | 'restore' | 'revision_restore';
87
+ export type RevisionOp = 'write' | 'verify' | 'archive' | 'restore' | 'revision_restore' | 'split';
84
88
  /** Every rejection the people surface makes. The message names the path. */
85
89
  export declare class PersonCardError extends Error {
86
90
  constructor(message: string);
@@ -112,6 +116,16 @@ export declare function fieldState(env: WhereEnvelope, nowMs: number): FieldStat
112
116
  export declare function renderCard(card: PersonCard, nowMs: number, opts?: {
113
117
  worstCase?: boolean;
114
118
  }): string;
119
+ interface RawRow {
120
+ slug: string;
121
+ name: string;
122
+ card_json: string;
123
+ archived_at: string | null;
124
+ archived_by: string | null;
125
+ archive_reason: string | null;
126
+ updated: string;
127
+ synthesized_at: string | null;
128
+ }
115
129
  export interface PeopleStoreConfig {
116
130
  dbPath: string;
117
131
  /** Open read-only and never migrate. Reads on an unmigrated store see no cards. */
@@ -119,6 +133,8 @@ export interface PeopleStoreConfig {
119
133
  busyTimeoutMs?: number;
120
134
  /** Clock, for tests. */
121
135
  now?: () => number;
136
+ /** Who is writing (an agent's name), recorded on every revision it makes. */
137
+ actor?: string;
122
138
  }
123
139
  /** Thrown when a stored row cannot be turned back into a card. */
124
140
  export declare class UnreadableCardError extends Error {
@@ -127,6 +143,7 @@ export declare class UnreadableCardError extends Error {
127
143
  export declare class PeopleStore {
128
144
  private db;
129
145
  private readonly now;
146
+ private readonly actor;
130
147
  /** False on a read-only store whose tables do not exist yet. */
131
148
  readonly migrated: boolean;
132
149
  constructor(config: PeopleStoreConfig);
@@ -163,6 +180,24 @@ export declare class PeopleStore {
163
180
  read(slug: string, includeArchived?: boolean): PersonReadResult;
164
181
  list(includeArchived?: boolean): PersonListEntry[];
165
182
  history(slug: string): PersonRevisionMeta[];
183
+ /**
184
+ * Put a card in place as is, keeping the row's timestamps and archive state
185
+ * (a migration, not an edit). Recorded as a 'split' revision when a row is
186
+ * replaced.
187
+ */
188
+ replaceCard(slug: string, card: PersonCard, keep: Pick<RawRow, 'updated' | 'synthesized_at' | 'archived_at' | 'archived_by' | 'archive_reason'>): void;
189
+ /** The raw stored rows (timestamps and archive state included), for a migration. */
190
+ rows(): Array<{
191
+ slug: string;
192
+ card: PersonCard;
193
+ updated: string;
194
+ synthesized_at: string | null;
195
+ archived_at: string | null;
196
+ archived_by: string | null;
197
+ archive_reason: string | null;
198
+ }>;
199
+ /** This database's `user` memories about a person, newer than `since` (all of them when null). */
200
+ memoriesSince(slug: string, since: string | null): number;
166
201
  /** A revision's snapshot (the whole previous row), for tests and inspection. */
167
202
  revisionSnapshot(id: number): {
168
203
  slug: string;
@@ -190,3 +225,4 @@ export interface RoomOptions {
190
225
  * block; one bad row gives that slug the unreadable line and the rest render.
191
226
  */
192
227
  export declare function roomBlock(contextDir: string, people: readonly string[] | undefined, opts?: RoomOptions): string | null;
228
+ export {};