@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 +37 -1
- package/README.md +1 -0
- package/dist/backends/migrations.js +9 -0
- package/dist/backends/people-household.d.ts +77 -0
- package/dist/backends/people-household.js +305 -0
- package/dist/backends/people.d.ts +37 -1
- package/dist/backends/people.js +42 -7
- package/dist/cli/household.js +22 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +12 -2
- package/dist/household.d.ts +16 -0
- package/dist/household.js +70 -0
- package/dist/server.js +4 -2
- package/dist/tools/person.d.ts +1 -0
- package/dist/tools/person.js +19 -6
- package/dist/transport/http-server.js +10 -1
- package/package.json +1 -1
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.
|
|
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 {};
|
package/dist/backends/people.js
CHANGED
|
@@ -313,7 +313,7 @@ function canonical(v) {
|
|
|
313
313
|
return JSON.stringify(v);
|
|
314
314
|
}
|
|
315
315
|
// ─── Store ───────────────────────────────────────────────────────────────────
|
|
316
|
-
const
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
m.
|
|
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(
|
|
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);
|
package/dist/cli/household.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/household.d.ts
CHANGED
|
@@ -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
|
package/dist/tools/person.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/person.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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.
|
|
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",
|