@jbarket/loomai 0.10.0 → 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,41 @@ 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
+
15
50
  ## [0.10.0] - 2026-10-24
16
51
 
17
52
  ### Security
@@ -697,7 +732,8 @@ Initial public release.
697
732
  stack and all added external-service dependencies or operational
698
733
  overhead.
699
734
 
700
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...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
701
737
  [0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
702
738
  [0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
703
739
  [0.9.1]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...v0.9.1
package/README.md CHANGED
@@ -660,6 +660,7 @@ exactly as before.
660
660
  ```bash
661
661
  loom household init art # ~/.config/loom/art → agents/art (a symlink stays)
662
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)
663
664
  loom token create --agent art --label "Art · house on wunder" # printed once
664
665
  loom token create --owner --label "Jonathan · page"
665
666
  loom token list
@@ -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 {};
@@ -313,7 +313,7 @@ function canonical(v) {
313
313
  return JSON.stringify(v);
314
314
  }
315
315
  // ─── Store ───────────────────────────────────────────────────────────────────
316
- const PEOPLE_MIGRATION = 'people_cards';
316
+ const PEOPLE_MIGRATIONS = ['people_cards', 'person_revisions_actor'];
317
317
  function hasTable(db, table) {
318
318
  return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
319
319
  }
@@ -339,19 +339,23 @@ function parseRow(raw) {
339
339
  export class PeopleStore {
340
340
  db;
341
341
  now;
342
+ actor;
342
343
  /** False on a read-only store whose tables do not exist yet. */
343
344
  migrated;
344
345
  constructor(config) {
345
346
  this.now = config.now ?? Date.now;
347
+ this.actor = config.actor ?? null;
346
348
  this.db = new BetterSqlite3(config.dbPath, config.readonly ? { readonly: true } : {});
347
349
  this.db.pragma(`busy_timeout = ${config.busyTimeoutMs ?? 5000}`);
348
350
  if (!config.readonly) {
349
351
  this.db.pragma('journal_mode = WAL');
350
352
  // Only this feature's migration: the people tables do not depend on the
351
353
  // memories schema, and a raw store must not half-run the others.
352
- const m = MIGRATIONS.find((x) => x.id === PEOPLE_MIGRATION);
353
- if (m.pending(this.db))
354
- m.run(this.db);
354
+ for (const id of PEOPLE_MIGRATIONS) {
355
+ const m = MIGRATIONS.find((x) => x.id === id);
356
+ if (m.pending(this.db))
357
+ m.run(this.db);
358
+ }
355
359
  }
356
360
  this.migrated = hasTable(this.db, 'people') && hasTable(this.db, 'person_revisions');
357
361
  }
@@ -368,8 +372,8 @@ export class PeopleStore {
368
372
  const snap = { ...prev, card: JSON.parse(prev.card_json) };
369
373
  delete snap.card_json;
370
374
  const id = Number(this.db
371
- .prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at) VALUES (?, ?, ?, ?)')
372
- .run(prev.slug, JSON.stringify(snap), op, at).lastInsertRowid);
375
+ .prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at, actor) VALUES (?, ?, ?, ?, ?)')
376
+ .run(prev.slug, JSON.stringify(snap), op, at, this.actor).lastInsertRowid);
373
377
  this.db
374
378
  .prepare(`DELETE FROM person_revisions WHERE slug = ? AND id NOT IN
375
379
  (SELECT id FROM person_revisions WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
@@ -681,10 +685,41 @@ export class PeopleStore {
681
685
  fail(`slug: ${slugErr}`);
682
686
  if (!this.migrated)
683
687
  return [];
688
+ const hasActor = this.db.pragma('table_info(person_revisions)').some((c) => c.name === 'actor');
684
689
  return this.db
685
- .prepare('SELECT id, op, replaced_at FROM person_revisions WHERE slug = ? ORDER BY id DESC')
690
+ .prepare(`SELECT id, op, replaced_at${hasActor ? ', actor' : ''} FROM person_revisions WHERE slug = ? ORDER BY id DESC`)
686
691
  .all(slug);
687
692
  }
693
+ /**
694
+ * Put a card in place as is, keeping the row's timestamps and archive state
695
+ * (a migration, not an edit). Recorded as a 'split' revision when a row is
696
+ * replaced.
697
+ */
698
+ replaceCard(slug, card, keep) {
699
+ validateCard(card, null);
700
+ this.mutate(() => {
701
+ const existing = this.readRow(slug);
702
+ if (existing)
703
+ this.snapshot(existing, 'split', this.nowIso());
704
+ this.store({ slug, name: card.name, card_json: JSON.stringify(card), ...keep });
705
+ });
706
+ }
707
+ /** The raw stored rows (timestamps and archive state included), for a migration. */
708
+ rows() {
709
+ if (!this.migrated)
710
+ return [];
711
+ return this.db.prepare('SELECT slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at FROM people ORDER BY slug').all()
712
+ .map((r) => ({ slug: r.slug, card: JSON.parse(r.card_json), updated: r.updated, synthesized_at: r.synthesized_at, archived_at: r.archived_at, archived_by: r.archived_by, archive_reason: r.archive_reason }));
713
+ }
714
+ /** This database's `user` memories about a person, newer than `since` (all of them when null). */
715
+ memoriesSince(slug, since) {
716
+ if (!hasTable(this.db, 'memories'))
717
+ return 0;
718
+ const nowIso = new Date(this.now()).toISOString();
719
+ return this.db.prepare(`SELECT COUNT(*) AS n FROM memories
720
+ WHERE category = 'user' AND archived = 0 AND json_extract(metadata, '$.person') = ?
721
+ AND (expires_at IS NULL OR expires_at > ?) AND (? IS NULL OR created > ?)`).get(slug, nowIso, since, since).n;
722
+ }
688
723
  /** A revision's snapshot (the whole previous row), for tests and inspection. */
689
724
  revisionSnapshot(id) {
690
725
  const rev = this.db.prepare('SELECT snapshot_json FROM person_revisions WHERE id = ?').get(id);
@@ -3,7 +3,8 @@
3
3
  * neither is a remote verb, so they never run through /cli. They read and
4
4
  * write $LOOM_HOME (default ~/.config/loom) directly.
5
5
  */
6
- import { addAgent, createToken, householdHome, HouseholdError, initHousehold, loadHousehold, loadTokens, parseRole, revokeToken, } from '../household.js';
6
+ import { addAgent, agentDir, moveKnowledgeToCommons, createToken, householdHome, HouseholdError, initHousehold, loadHousehold, loadTokens, parseRole, revokeToken, } from '../household.js';
7
+ import { splitPeopleIntoCommons } from '../backends/people-household.js';
7
8
  const HOUSEHOLD_USAGE = `Usage: loom household <command>
8
9
 
9
10
  show The household at $LOOM_HOME: agents, the default, the owner
@@ -11,6 +12,10 @@ const HOUSEHOLD_USAGE = `Usage: loom household <command>
11
12
  Make $LOOM_HOME/<agent> (a context dir) the first agent of a new
12
13
  household: it moves to agents/<agent>, and a symlink stays behind
13
14
  add <agent> Add an agent whose directory already exists under agents/
15
+ commons Move the default agent's knowledge.db to commons/knowledge.db and
16
+ split every agent's person cards (handles and facts to the commons
17
+ directory, the rest stays as its relationships). Once, with
18
+ every loom daemon on this household stopped
14
19
 
15
20
  $LOOM_HOME defaults to ~/.config/loom.
16
21
  `;
@@ -52,7 +57,22 @@ export async function runHousehold(argv, io) {
52
57
  return 2;
53
58
  }
54
59
  const h = initHousehold(householdHome(io.env), agent, { owner: flag(rest, '--owner') });
55
- io.stdout(`Household at ${h.home}: ${agent} moved to agents/${agent}; ${h.home}/${agent} is now a symlink to it.\n`);
60
+ const cards = splitPeopleIntoCommons(h, agent, agentDir(h, agent));
61
+ io.stdout(`Household at ${h.home}: ${agent} moved to agents/${agent}; ${h.home}/${agent} is now a symlink to it. ${cards} person card${cards === 1 ? '' : 's'} split into the commons directory.\n`);
62
+ return 0;
63
+ }
64
+ if (sub === 'commons') {
65
+ const h = need(io);
66
+ if (!h)
67
+ return 1;
68
+ const moved = moveKnowledgeToCommons(h, h.default);
69
+ io.stdout(moved ? `Knowledge moved to ${moved}; every agent shares it now.\n` : `agents/${h.default} has no knowledge.db to move.\n`);
70
+ // Every agent's cards, so the daemon's "haven't been split" refusal can
71
+ // always be cleared here.
72
+ for (const a of h.agents) {
73
+ const cards = splitPeopleIntoCommons(h, a, agentDir(h, a));
74
+ io.stdout(`${a}: ${cards} person card${cards === 1 ? '' : 's'} split (handles and facts in the commons directory, the rest as ${a}'s relationships).\n`);
75
+ }
56
76
  return 0;
57
77
  }
58
78
  if (sub === 'add') {
package/dist/config.d.ts CHANGED
@@ -24,6 +24,11 @@ export declare function assertContextBootable(contextDir: string, defaultPath?:
24
24
  */
25
25
  export declare function resolveRepoRoot(): string;
26
26
  export declare function resolveSqliteDbPath(contextDir: string): string;
27
+ /**
28
+ * Knowledge is facts about the world. In a household it's the commons, shared
29
+ * by every agent (docs/specs/household.md); otherwise it lives in the
30
+ * agent's own context dir, as it always has.
31
+ */
27
32
  export declare function resolveKnowledgeDbPath(contextDir: string): string;
28
33
  /**
29
34
  * Resolve the acting identity's name for actor attribution (knowledge
package/dist/config.js CHANGED
@@ -18,7 +18,7 @@ import { resolve, dirname, basename } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'node:fs';
21
- import { contextFromHome } from './household.js';
21
+ import { commonsKnowledgePath, contextFromHome, householdOf } from './household.js';
22
22
  /**
23
23
  * The single canonical helper for the default context path. Every call
24
24
  * site that needs `~/.config/loom/default` must go through this —
@@ -72,8 +72,18 @@ export function resolveRepoRoot() {
72
72
  export function resolveSqliteDbPath(contextDir) {
73
73
  return process.env.LOOM_SQLITE_DB_PATH ?? resolve(contextDir, 'memories.db');
74
74
  }
75
+ /**
76
+ * Knowledge is facts about the world. In a household it's the commons, shared
77
+ * by every agent (docs/specs/household.md); otherwise it lives in the
78
+ * agent's own context dir, as it always has.
79
+ */
75
80
  export function resolveKnowledgeDbPath(contextDir) {
76
- return process.env.LOOM_KNOWLEDGE_DB_PATH ?? resolve(contextDir, 'knowledge.db');
81
+ if (process.env.LOOM_KNOWLEDGE_DB_PATH)
82
+ return process.env.LOOM_KNOWLEDGE_DB_PATH;
83
+ const hh = householdOf(contextDir);
84
+ if (hh)
85
+ return commonsKnowledgePath(hh.household);
86
+ return resolve(contextDir, 'knowledge.db');
77
87
  }
78
88
  /**
79
89
  * Resolve the acting identity's name for actor attribution (knowledge
@@ -99,3 +99,19 @@ export declare function addAgent(h: Household, agent: string): Household;
99
99
  * `<home>/agents/<name>` serves that household; see householdOf.)
100
100
  */
101
101
  export declare function contextFromHome(env?: NodeJS.ProcessEnv): string | undefined;
102
+ export declare function commonsDir(h: Household): string;
103
+ /** The household's shared knowledge store. */
104
+ export declare function commonsKnowledgePath(h: Household): string;
105
+ /**
106
+ * Why this household can't serve yet, or null. An agent's own knowledge.db
107
+ * beside the commons would quietly fork the knowledge base, so both "only the
108
+ * agent's" (not moved yet) and "both" (needs a merge) are refusals.
109
+ */
110
+ export declare function commonsProblem(h: Household): string | null;
111
+ /**
112
+ * Move one agent's knowledge.db to the commons: checkpoint its WAL into the
113
+ * main file, then rename it (and any sidecars). Refuses when the commons
114
+ * already has a knowledge store. Run with every daemon on this household
115
+ * stopped.
116
+ */
117
+ export declare function moveKnowledgeToCommons(h: Household, agent: string): string | null;
package/dist/household.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
17
17
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
+ import BetterSqlite3 from 'better-sqlite3';
19
20
  import { basename, dirname, join, resolve } from 'node:path';
20
21
  export const AGENT_NAME = /^[a-z][a-z0-9-]{0,39}$/;
21
22
  const HOUSEHOLD_FILE = 'household.json';
@@ -333,6 +334,21 @@ export function initHousehold(home, agent, opts = {}) {
333
334
  renameSync(from, to);
334
335
  symlinkSync(join('agents', agent), from);
335
336
  const h = { home, agents: [agent], default: agent, owner: opts.owner };
337
+ // The first agent's knowledge becomes the household's commons.
338
+ if (existsSync(join(to, 'knowledge.db')) && !existsSync(join(home, 'commons', 'knowledge.db'))) {
339
+ const kdb = new BetterSqlite3(join(to, 'knowledge.db'));
340
+ try {
341
+ kdb.pragma('wal_checkpoint(TRUNCATE)');
342
+ }
343
+ finally {
344
+ kdb.close();
345
+ }
346
+ mkdirSync(join(home, 'commons'), { recursive: true, mode: 0o700 });
347
+ for (const ext of SQLITE_SIDECARS) {
348
+ if (existsSync(join(to, 'knowledge.db') + ext))
349
+ renameSync(join(to, 'knowledge.db') + ext, join(home, 'commons', 'knowledge.db') + ext);
350
+ }
351
+ }
336
352
  withLock(home, () => writePrivate(join(home, HOUSEHOLD_FILE), JSON.stringify({ v: 1, agents: h.agents, default: h.default, ...(h.owner ? { owner: h.owner } : {}) }, null, 2) + '\n'));
337
353
  return h;
338
354
  }
@@ -387,3 +403,57 @@ export function contextFromHome(env = process.env) {
387
403
  throw new HouseholdError(`LOOM_AGENT=${agent} isn't in the household at ${h.home} (${h.agents.join(', ')})`);
388
404
  return agentDir(h, agent);
389
405
  }
406
+ // ─── The commons ─────────────────────────────────────────────────────────────
407
+ export function commonsDir(h) {
408
+ return join(h.home, 'commons');
409
+ }
410
+ /** The household's shared knowledge store. */
411
+ export function commonsKnowledgePath(h) {
412
+ return join(commonsDir(h), 'knowledge.db');
413
+ }
414
+ const SQLITE_SIDECARS = ['', '-wal', '-shm'];
415
+ /**
416
+ * Why this household can't serve yet, or null. An agent's own knowledge.db
417
+ * beside the commons would quietly fork the knowledge base, so both "only the
418
+ * agent's" (not moved yet) and "both" (needs a merge) are refusals.
419
+ */
420
+ export function commonsProblem(h) {
421
+ const commons = existsSync(commonsKnowledgePath(h));
422
+ for (const a of h.agents) {
423
+ if (!existsSync(join(agentDir(h, a), 'knowledge.db')))
424
+ continue;
425
+ return commons
426
+ ? `agents/${a}/knowledge.db exists beside commons/knowledge.db; merging an agent's knowledge into the commons isn't automatic yet, so move one aside`
427
+ : `agents/${a}/knowledge.db hasn't moved to the commons; with every loom daemon stopped, run: loom household commons`;
428
+ }
429
+ return null;
430
+ }
431
+ /**
432
+ * Move one agent's knowledge.db to the commons: checkpoint its WAL into the
433
+ * main file, then rename it (and any sidecars). Refuses when the commons
434
+ * already has a knowledge store. Run with every daemon on this household
435
+ * stopped.
436
+ */
437
+ export function moveKnowledgeToCommons(h, agent) {
438
+ const from = join(agentDir(h, agent), 'knowledge.db');
439
+ if (!existsSync(from))
440
+ return null;
441
+ const to = commonsKnowledgePath(h);
442
+ if (existsSync(to))
443
+ throw new HouseholdError(`${to} already exists; merging another agent's knowledge into it isn't automatic yet`);
444
+ return withLock(h.home, () => {
445
+ const db = new BetterSqlite3(from);
446
+ try {
447
+ db.pragma('wal_checkpoint(TRUNCATE)');
448
+ }
449
+ finally {
450
+ db.close();
451
+ }
452
+ mkdirSync(commonsDir(h), { recursive: true, mode: 0o700 });
453
+ for (const ext of SQLITE_SIDECARS) {
454
+ if (existsSync(from + ext))
455
+ renameSync(from + ext, to + ext);
456
+ }
457
+ return to;
458
+ });
459
+ }
package/dist/server.js CHANGED
@@ -643,10 +643,12 @@ export function createLoomServer(config) {
643
643
  inputSchema: z.object({
644
644
  slug: slugParam,
645
645
  revision_id: z.number().int().positive().describe('Revision id from person_history.'),
646
+ store: z.enum(['directory', 'relationship']).optional().describe('In a household, which half the revision is from (person_history says): the shared directory ' +
647
+ 'entry, or this agent\'s relationship (the default). Ignored outside a household.'),
646
648
  }).strict(),
647
649
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
648
- }, async ({ slug, revision_id }) => {
649
- const result = await personRevisionRestore(contextDir, { slug, revision_id });
650
+ }, async ({ slug, revision_id, store }) => {
651
+ const result = await personRevisionRestore(contextDir, { slug, revision_id, store });
650
652
  return { content: [{ type: 'text', text: result }] };
651
653
  });
652
654
  // MCP-App render probe — env-gated, throwaway. Not part of the tool surface
@@ -16,6 +16,7 @@ export declare function personRestore(contextDir: string, input: {
16
16
  export declare function personRevisionRestore(contextDir: string, input: {
17
17
  slug: string;
18
18
  revision_id: number;
19
+ store?: 'directory' | 'relationship';
19
20
  }): Promise<string>;
20
21
  /** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
21
22
  export declare function readPerson(contextDir: string, slug: string, includeArchived?: boolean): PersonReadResult;
@@ -9,7 +9,9 @@
9
9
  * loom tool. The CLI (`loom person`) calls the read functions here too.
10
10
  */
11
11
  import { resolveIdentityName } from '../config.js';
12
- import { openPeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
12
+ import { householdOf } from '../household.js';
13
+ import { openHouseholdPeople } from '../backends/people-household.js';
14
+ import { openPeopleStore, PeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
13
15
  const json = (v) => JSON.stringify(v, null, 2);
14
16
  /** Slugs validate before anything else, store or no store (criterion 11). */
15
17
  function assertSlug(slug) {
@@ -22,9 +24,20 @@ function errorText(e) {
22
24
  return `Error: ${e.message}`;
23
25
  return `Error: ${e.message ?? String(e)}`;
24
26
  }
27
+ /**
28
+ * The people a context dir sees. In a household: the commons directory plus
29
+ * this agent's relationships (backends/people-household.ts). Otherwise the
30
+ * one store in the agent's memories.db, as before.
31
+ */
32
+ function openPeople(contextDir, opts = {}) {
33
+ const hh = householdOf(contextDir);
34
+ if (hh)
35
+ return openHouseholdPeople(hh.household, hh.agent, contextDir, opts);
36
+ return openPeopleStore(contextDir, opts);
37
+ }
25
38
  function withWritable(contextDir, fn) {
26
39
  try {
27
- const store = openPeopleStore(contextDir);
40
+ const store = openPeople(contextDir);
28
41
  try {
29
42
  return json(fn(store));
30
43
  }
@@ -50,12 +63,12 @@ export async function personRestore(contextDir, input) {
50
63
  return withWritable(contextDir, (s) => s.restore(input));
51
64
  }
52
65
  export async function personRevisionRestore(contextDir, input) {
53
- return withWritable(contextDir, (s) => s.revisionRestore(input));
66
+ return withWritable(contextDir, (s) => (s instanceof PeopleStore ? s.revisionRestore({ slug: input.slug, revision_id: input.revision_id }) : s.revisionRestore(input)));
54
67
  }
55
68
  /** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
56
69
  export function readPerson(contextDir, slug, includeArchived = false) {
57
70
  assertSlug(slug);
58
- const store = openPeopleStore(contextDir, { readonly: true });
71
+ const store = openPeople(contextDir, { readonly: true });
59
72
  if (!store)
60
73
  return { found: false };
61
74
  try {
@@ -66,7 +79,7 @@ export function readPerson(contextDir, slug, includeArchived = false) {
66
79
  }
67
80
  }
68
81
  export function listPeople(contextDir, includeArchived = false) {
69
- const store = openPeopleStore(contextDir, { readonly: true });
82
+ const store = openPeople(contextDir, { readonly: true });
70
83
  if (!store)
71
84
  return [];
72
85
  try {
@@ -95,7 +108,7 @@ export async function personList(contextDir, input) {
95
108
  export async function personHistory(contextDir, input) {
96
109
  try {
97
110
  assertSlug(input.slug);
98
- const store = openPeopleStore(contextDir, { readonly: true });
111
+ const store = openPeople(contextDir, { readonly: true });
99
112
  if (!store)
100
113
  return json([]);
101
114
  try {
@@ -41,7 +41,8 @@ import { createBackend } from '../backends/index.js';
41
41
  import { handleCliBody } from './cli-endpoint.js';
42
42
  import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
43
43
  import { createKnowledgeOnlyServer } from './knowledge-server.js';
44
- import { agentDir, assertRealAgentDir, roleString, TokenResolver } from '../household.js';
44
+ import { hasUnsplitPeople } from '../backends/people-household.js';
45
+ import { agentDir, assertRealAgentDir, commonsProblem, roleString, TokenResolver } from '../household.js';
45
46
  import { createHash, timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto';
46
47
  import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
47
48
  /**
@@ -142,6 +143,14 @@ export async function startHttpServer(opts) {
142
143
  }
143
144
  for (const a of household.agents)
144
145
  assertRealAgentDir(household, a);
146
+ const problem = commonsProblem(household);
147
+ if (problem)
148
+ throw new Error(`startHttpServer: ${problem}`);
149
+ for (const a of household.agents) {
150
+ if (hasUnsplitPeople(agentDir(household, a))) {
151
+ throw new Error(`startHttpServer: agents/${a}'s person cards haven't been split into the commons; with every loom daemon stopped, run: loom household commons`);
152
+ }
153
+ }
145
154
  }
146
155
  const resolver = household ? new TokenResolver(household) : undefined;
147
156
  const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",