@jbarket/loomai 0.10.0 → 0.12.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 +57 -1
- package/README.md +36 -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/bundle.d.ts +80 -0
- package/dist/bundle.js +377 -0
- package/dist/cli/bundle.d.ts +3 -0
- package/dist/cli/bundle.js +107 -0
- package/dist/cli/household.js +22 -2
- package/dist/cli/index.js +10 -0
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- 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,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.12.0] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **The bundle: the household on a USB stick** (docs/specs/household.md,
|
|
20
|
+
phase 3).
|
|
21
|
+
- **`loom export --to <file> --recipient <age1…>`** writes the whole
|
|
22
|
+
household into one file, encrypted with age to the owner's key: its
|
|
23
|
+
files and tokens, the commons, every agent's directory (SQLite through
|
|
24
|
+
the backup API), and each piece's own part (`loom-<piece> bundle export`,
|
|
25
|
+
encrypted by the piece).
|
|
26
|
+
- **`loom restore <file> --identity <key>`** lays it down in an empty
|
|
27
|
+
LOOM_HOME and hands each piece its part (`bundle import`). A piece that isn't
|
|
28
|
+
installed keeps its part in `pieces-pending/`.
|
|
29
|
+
- **`--drill`** restores into a throwaway directory and compares it with
|
|
30
|
+
the live household, touching nothing live.
|
|
31
|
+
- **A plain context dir** exports as a household of one, and restores as
|
|
32
|
+
one with its cards split.
|
|
33
|
+
|
|
34
|
+
## [0.11.0] - 2026-10-24
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **The household, phase 2b: one directory entry per person, a relationship
|
|
39
|
+
per agent.** In a household a person card is kept in two places:
|
|
40
|
+
- **The directory** (`commons/people.db`): name, handles (`where`), and
|
|
41
|
+
the facts in `how` (`tz`, `timezone`, `pronouns`, `languages`), shared
|
|
42
|
+
by every agent.
|
|
43
|
+
- **Each agent's relationship** (its own `memories.db`): the summary, the
|
|
44
|
+
rest of `how` (how *this* agent talks to them) and `may`.
|
|
45
|
+
|
|
46
|
+
How it behaves:
|
|
47
|
+
- A read merges the two halves into one card, rendered exactly as before.
|
|
48
|
+
- A write splits by field. Both halves are rehearsed in memory first, so
|
|
49
|
+
a write either half would refuse changes neither.
|
|
50
|
+
- Revisions record the agent that made them (`person_revisions.actor`),
|
|
51
|
+
and `person_history` says which half each revision came from.
|
|
52
|
+
- "Memories newer than the card" counts each agent's own memories.
|
|
53
|
+
|
|
54
|
+
Moving in:
|
|
55
|
+
- `loom household commons` and `init` split an existing agent's cards,
|
|
56
|
+
checking first that every card still renders the same text. Running it
|
|
57
|
+
again is safe.
|
|
58
|
+
- A household daemon refuses to start with cards that haven't been split.
|
|
59
|
+
- **The household, phase 2a: knowledge is the commons.**
|
|
60
|
+
- In a household, every agent reads and writes `commons/knowledge.db`.
|
|
61
|
+
- `loom household init` moves the first agent's knowledge there.
|
|
62
|
+
- `loom household commons` moves it for a household made before this
|
|
63
|
+
release. It checkpoints the WAL first, and must run with every daemon
|
|
64
|
+
stopped.
|
|
65
|
+
- A household daemon refuses to start while an agent still has its own
|
|
66
|
+
`knowledge.db`, so the knowledge base can't quietly fork.
|
|
67
|
+
- A plain context dir keeps its own knowledge, as before.
|
|
68
|
+
|
|
15
69
|
## [0.10.0] - 2026-10-24
|
|
16
70
|
|
|
17
71
|
### Security
|
|
@@ -697,7 +751,9 @@ Initial public release.
|
|
|
697
751
|
stack and all added external-service dependencies or operational
|
|
698
752
|
overhead.
|
|
699
753
|
|
|
700
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
754
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...HEAD
|
|
755
|
+
[0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
|
|
756
|
+
[0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
|
|
701
757
|
[0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
|
|
702
758
|
[0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
|
|
703
759
|
[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
|
|
@@ -676,6 +677,41 @@ dir is `<home>/agents/<name>` next to a `household.json` serves the whole
|
|
|
676
677
|
household. A process with `LOOM_HOME` set and no context dir uses
|
|
677
678
|
`LOOM_AGENT`, or the household's default.
|
|
678
679
|
|
|
680
|
+
### The bundle — the household on a USB stick
|
|
681
|
+
|
|
682
|
+
```bash
|
|
683
|
+
loom export --to household.bundle --recipient age1… # your key, never the box's
|
|
684
|
+
loom restore household.bundle --identity ~/owner.key --drill # does it really come back?
|
|
685
|
+
loom restore household.bundle --identity ~/owner.key --to ~/.config/loom # on a new machine
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
**`loom export`** writes the whole household into one file, encrypted with
|
|
689
|
+
[age](https://age-encryption.org) to a recipient the owner holds (a YubiKey,
|
|
690
|
+
or a key kept in a password manager). The file contains:
|
|
691
|
+
- the household's files and tokens;
|
|
692
|
+
- the commons;
|
|
693
|
+
- every agent's directory. Databases go through SQLite's backup API, never
|
|
694
|
+
as a raw copy of a live file. Old `*.bak` files and `backup-*` folders
|
|
695
|
+
are left out.
|
|
696
|
+
- each installed piece's own part (`loom-<piece> bundle export`), which the piece
|
|
697
|
+
encrypts itself to the same recipient. Core never sees inside it.
|
|
698
|
+
|
|
699
|
+
A plain context dir exports as a household of one.
|
|
700
|
+
|
|
701
|
+
**`loom restore`** lays a bundle down in an empty `LOOM_HOME` (never over a
|
|
702
|
+
live one). It then hands each installed piece its part (`loom-<piece>
|
|
703
|
+
import`, with `LOOM_IMPORT_IDENTITY` naming the key so the piece can
|
|
704
|
+
re-encrypt for the new machine). A piece that isn't installed yet keeps its
|
|
705
|
+
part in `pieces-pending/`.
|
|
706
|
+
|
|
707
|
+
**`--drill`** restores into a throwaway directory instead and compares the
|
|
708
|
+
result with the live household: identities, memory counts, every card,
|
|
709
|
+
knowledge, tokens. Nothing live is touched.
|
|
710
|
+
|
|
711
|
+
A restore trusts what's in the bundle. age encrypts, but it doesn't prove who
|
|
712
|
+
made the file, so only restore bundles you made. Needs the `age` and `tar`
|
|
713
|
+
programs.
|
|
714
|
+
|
|
679
715
|
### Harness manifests and self-describe
|
|
680
716
|
|
|
681
717
|
A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
|
|
@@ -337,6 +337,15 @@ export const MIGRATIONS = [
|
|
|
337
337
|
}).immediate();
|
|
338
338
|
},
|
|
339
339
|
},
|
|
340
|
+
{
|
|
341
|
+
id: 'person_revisions_actor',
|
|
342
|
+
description: 'Add person_revisions.actor: which agent made the change a revision records, so a ' +
|
|
343
|
+
'shared directory entry shows who changed what (docs/specs/household.md)',
|
|
344
|
+
pending: (db) => hasTable(db, 'person_revisions') && !hasColumn(db, 'person_revisions', 'actor'),
|
|
345
|
+
run: (db) => {
|
|
346
|
+
db.prepare('ALTER TABLE person_revisions ADD COLUMN actor TEXT').run();
|
|
347
|
+
},
|
|
348
|
+
},
|
|
340
349
|
];
|
|
341
350
|
/**
|
|
342
351
|
* Apply pending migrations to a database.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { PeopleStore, type MutationResult, type PersonCard, type PersonListEntry, type PersonReadResult, type PersonRevisionMeta, type PersonRow, type PersonWriteInput } from './people.js';
|
|
2
|
+
import { type Household } from '../household.js';
|
|
3
|
+
/** `how` fields that are facts about the person, not how one agent talks to them. */
|
|
4
|
+
export declare const FACT_KEYS: ReadonlySet<string>;
|
|
5
|
+
export declare function commonsPeoplePath(h: Household): string;
|
|
6
|
+
/** The directory half and the relationship half of a whole card. */
|
|
7
|
+
export declare function splitCard(card: PersonCard): {
|
|
8
|
+
directory: PersonCard;
|
|
9
|
+
relationship: PersonCard;
|
|
10
|
+
};
|
|
11
|
+
/** One card from its two halves. */
|
|
12
|
+
export declare function mergeCard(d: PersonCard, r: PersonCard | null): PersonCard;
|
|
13
|
+
export declare class HouseholdPeople {
|
|
14
|
+
private readonly directory;
|
|
15
|
+
private readonly relationship;
|
|
16
|
+
private readonly now;
|
|
17
|
+
constructor(directory: PeopleStore, relationship: PeopleStore, now?: () => number);
|
|
18
|
+
close(): void;
|
|
19
|
+
getRow(slug: string): PersonRow | null;
|
|
20
|
+
read(slug: string, includeArchived?: boolean): PersonReadResult;
|
|
21
|
+
list(includeArchived?: boolean): PersonListEntry[];
|
|
22
|
+
/**
|
|
23
|
+
* Split a write by field: name, `where` and the fact keys of `how` go to the
|
|
24
|
+
* directory; summary, the rest of `how` and `may` to this agent's
|
|
25
|
+
* relationship. Both halves are rehearsed against in-memory copies first,
|
|
26
|
+
* so a write either half would refuse changes neither.
|
|
27
|
+
*/
|
|
28
|
+
write(input: PersonWriteInput): MutationResult;
|
|
29
|
+
verify(input: {
|
|
30
|
+
slug: string;
|
|
31
|
+
fields: string[];
|
|
32
|
+
verified_at?: string;
|
|
33
|
+
source?: string;
|
|
34
|
+
}): MutationResult;
|
|
35
|
+
archive(input: {
|
|
36
|
+
slug: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
actor: string;
|
|
39
|
+
}): MutationResult;
|
|
40
|
+
restore(input: {
|
|
41
|
+
slug: string;
|
|
42
|
+
}): MutationResult;
|
|
43
|
+
/** Revisions from both halves, newest first, each saying which half and which agent. */
|
|
44
|
+
history(slug: string): PersonRevisionMeta[];
|
|
45
|
+
/**
|
|
46
|
+
* Restore one half to a revision: `store` says which (the relationship,
|
|
47
|
+
* unless named). A relationship revision from before the split holds the
|
|
48
|
+
* whole old card; only its relationship half comes back, so handles and
|
|
49
|
+
* facts never return to the agent's own store.
|
|
50
|
+
*/
|
|
51
|
+
revisionRestore(input: {
|
|
52
|
+
slug: string;
|
|
53
|
+
revision_id: number;
|
|
54
|
+
store?: 'directory' | 'relationship';
|
|
55
|
+
}): MutationResult;
|
|
56
|
+
private mutation;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Open the people for a household agent: the commons directory plus the
|
|
60
|
+
* agent's relationships, both attributing revisions to the agent. A
|
|
61
|
+
* read-only open returns null when the directory doesn't exist yet.
|
|
62
|
+
*/
|
|
63
|
+
export declare function openHouseholdPeople(h: Household, agent: string, contextDir: string, opts?: {
|
|
64
|
+
readonly?: boolean;
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}): HouseholdPeople | null;
|
|
67
|
+
/**
|
|
68
|
+
* Move an agent's whole cards into the household split: the directory half
|
|
69
|
+
* to the commons, the relationship half back in place (timestamps and
|
|
70
|
+
* archive state kept). Every card is checked first: merged back together it
|
|
71
|
+
* must render the same text it does today, or nothing is written. Refuses a
|
|
72
|
+
* slug the directory already has (merging a second agent's people is a later
|
|
73
|
+
* step). Returns how many cards moved.
|
|
74
|
+
*/
|
|
75
|
+
export declare function splitPeopleIntoCommons(h: Household, agent: string, contextDir: string, nowMs?: number): number;
|
|
76
|
+
/** Whether an agent still holds directory fields (handles, facts) in its own cards: an unsplit store. */
|
|
77
|
+
export declare function hasUnsplitPeople(contextDir: string): boolean;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Person cards in a household (docs/specs/household.md, "The commons"): one
|
|
3
|
+
* card a reader sees, kept in two places.
|
|
4
|
+
*
|
|
5
|
+
* directory commons/people.db, one entry per human, shared by every
|
|
6
|
+
* agent: name, `where` (handles), and the facts in `how`
|
|
7
|
+
* that don't depend on who's asking (FACT_KEYS)
|
|
8
|
+
* relationship the agent's own memories.db: summary, the rest of `how`
|
|
9
|
+
* (how *this* agent talks to them), `may`
|
|
10
|
+
*
|
|
11
|
+
* A read merges the two into one card, rendered exactly as before. A write
|
|
12
|
+
* splits by field. Every revision names the agent that made it. No agent
|
|
13
|
+
* reads another's relationship: there's no API for it here.
|
|
14
|
+
*
|
|
15
|
+
* Same surface as PeopleStore, so the person tools don't care which they got.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { PeopleStore, PersonCardError, renderCard, } from './people.js';
|
|
20
|
+
import { resolveSqliteDbPath } from '../config.js';
|
|
21
|
+
import { commonsDir } from '../household.js';
|
|
22
|
+
/** `how` fields that are facts about the person, not how one agent talks to them. */
|
|
23
|
+
export const FACT_KEYS = new Set(['tz', 'timezone', 'pronouns', 'languages']);
|
|
24
|
+
export function commonsPeoplePath(h) {
|
|
25
|
+
return join(commonsDir(h), 'people.db');
|
|
26
|
+
}
|
|
27
|
+
function canonicalJson(v) {
|
|
28
|
+
if (Array.isArray(v))
|
|
29
|
+
return `[${v.map(canonicalJson).join(',')}]`;
|
|
30
|
+
if (v && typeof v === 'object')
|
|
31
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
|
|
32
|
+
return JSON.stringify(v);
|
|
33
|
+
}
|
|
34
|
+
function splitHow(how) {
|
|
35
|
+
const facts = {};
|
|
36
|
+
const rest = {};
|
|
37
|
+
for (const [k, v] of Object.entries(how ?? {}))
|
|
38
|
+
(FACT_KEYS.has(k) ? facts : rest)[k] = v;
|
|
39
|
+
return { facts, rest };
|
|
40
|
+
}
|
|
41
|
+
/** The directory half and the relationship half of a whole card. */
|
|
42
|
+
export function splitCard(card) {
|
|
43
|
+
const { facts, rest } = splitHow(card.how);
|
|
44
|
+
return {
|
|
45
|
+
directory: { slug: card.slug, name: card.name, where: card.where ?? {}, how: facts, may: [] },
|
|
46
|
+
relationship: {
|
|
47
|
+
slug: card.slug, name: card.name, ...(card.summary !== undefined ? { summary: card.summary } : {}),
|
|
48
|
+
where: {}, how: rest, may: card.may ?? [],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** One card from its two halves. */
|
|
53
|
+
export function mergeCard(d, r) {
|
|
54
|
+
return {
|
|
55
|
+
slug: d.slug,
|
|
56
|
+
name: d.name,
|
|
57
|
+
...(r?.summary !== undefined ? { summary: r.summary } : {}),
|
|
58
|
+
where: d.where ?? {},
|
|
59
|
+
how: { ...(r?.how ?? {}), ...(d.how ?? {}) },
|
|
60
|
+
may: r?.may ?? [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Try a write against an in-memory copy of the current row; throws exactly what the real write would. */
|
|
64
|
+
function rehearse(row, input, now) {
|
|
65
|
+
if (!input)
|
|
66
|
+
return;
|
|
67
|
+
const mem = new PeopleStore({ dbPath: ':memory:', now });
|
|
68
|
+
try {
|
|
69
|
+
if (row) {
|
|
70
|
+
mem.replaceCard(row.slug, row.card, {
|
|
71
|
+
updated: row.updated, synthesized_at: row.synthesized_at,
|
|
72
|
+
archived_at: row.archived_at, archived_by: row.archived_by, archive_reason: row.archive_reason,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
mem.write(input);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
mem.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class HouseholdPeople {
|
|
82
|
+
directory;
|
|
83
|
+
relationship;
|
|
84
|
+
now;
|
|
85
|
+
constructor(directory, relationship, now = Date.now) {
|
|
86
|
+
this.directory = directory;
|
|
87
|
+
this.relationship = relationship;
|
|
88
|
+
this.now = now;
|
|
89
|
+
}
|
|
90
|
+
close() {
|
|
91
|
+
this.directory.close();
|
|
92
|
+
this.relationship.close();
|
|
93
|
+
}
|
|
94
|
+
getRow(slug) {
|
|
95
|
+
const d = this.directory.getRow(slug);
|
|
96
|
+
if (!d)
|
|
97
|
+
return null;
|
|
98
|
+
const r = this.relationship.getRow(slug);
|
|
99
|
+
return {
|
|
100
|
+
...d,
|
|
101
|
+
card: mergeCard(d.card, r?.card ?? null),
|
|
102
|
+
updated: r && r.updated > d.updated ? r.updated : d.updated,
|
|
103
|
+
synthesized_at: r?.synthesized_at ?? null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
read(slug, includeArchived = false) {
|
|
107
|
+
const row = this.getRow(slug);
|
|
108
|
+
if (!row)
|
|
109
|
+
return this.directory.read(slug, includeArchived);
|
|
110
|
+
const archived = row.archived_at !== null;
|
|
111
|
+
if (archived && !includeArchived)
|
|
112
|
+
return { found: true, archived: true };
|
|
113
|
+
return {
|
|
114
|
+
found: true,
|
|
115
|
+
rendered: renderCard(row.card, this.now()) + (archived ? `\n_(archived ${row.archived_at} by ${row.archived_by}: ${row.archive_reason})_` : ''),
|
|
116
|
+
card: row.card,
|
|
117
|
+
archived,
|
|
118
|
+
updated: row.updated,
|
|
119
|
+
synthesized_at: row.synthesized_at,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
list(includeArchived = false) {
|
|
123
|
+
const rel = new Map(this.relationship.list(true).map((e) => [e.slug, e]));
|
|
124
|
+
return this.directory.list(includeArchived).map((e) => {
|
|
125
|
+
const r = rel.get(e.slug);
|
|
126
|
+
const since = r?.synthesized_at ?? null;
|
|
127
|
+
return {
|
|
128
|
+
...e,
|
|
129
|
+
updated: r && r.updated > e.updated ? r.updated : e.updated,
|
|
130
|
+
synthesized_at: since,
|
|
131
|
+
memories_since_synthesis: this.relationship.memoriesSince(e.slug, since),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Split a write by field: name, `where` and the fact keys of `how` go to the
|
|
137
|
+
* directory; summary, the rest of `how` and `may` to this agent's
|
|
138
|
+
* relationship. Both halves are rehearsed against in-memory copies first,
|
|
139
|
+
* so a write either half would refuse changes neither.
|
|
140
|
+
*/
|
|
141
|
+
write(input) {
|
|
142
|
+
const merge = input.merge ?? true;
|
|
143
|
+
const dirRow = this.directory.getRow(input.slug);
|
|
144
|
+
const relRow = this.relationship.getRow(input.slug);
|
|
145
|
+
const name = input.name ?? dirRow?.card.name;
|
|
146
|
+
if (name === undefined)
|
|
147
|
+
throw new PersonCardError('name: is required to create a card');
|
|
148
|
+
const { facts, rest } = splitHow(input.how);
|
|
149
|
+
const dirInput = input.name !== undefined || input.where !== undefined || Object.keys(facts).length > 0 || !dirRow || !merge
|
|
150
|
+
? { slug: input.slug, name, merge, ...(input.where !== undefined ? { where: input.where } : {}), how: facts }
|
|
151
|
+
: null;
|
|
152
|
+
const relInput = input.summary !== undefined || Object.keys(rest).length > 0 || input.may !== undefined || !merge || !relRow || (input.name !== undefined && relRow.card.name !== input.name)
|
|
153
|
+
? {
|
|
154
|
+
slug: input.slug, name, merge,
|
|
155
|
+
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
|
156
|
+
how: rest,
|
|
157
|
+
...(input.may !== undefined ? { may: input.may } : {}),
|
|
158
|
+
}
|
|
159
|
+
: null;
|
|
160
|
+
rehearse(dirRow, dirInput, this.now);
|
|
161
|
+
rehearse(relRow, relInput, this.now);
|
|
162
|
+
if (dirInput)
|
|
163
|
+
this.directory.write(dirInput);
|
|
164
|
+
if (relInput)
|
|
165
|
+
this.relationship.write(relInput);
|
|
166
|
+
return this.mutation(input.slug);
|
|
167
|
+
}
|
|
168
|
+
verify(input) {
|
|
169
|
+
this.directory.verify(input);
|
|
170
|
+
return this.mutation(input.slug);
|
|
171
|
+
}
|
|
172
|
+
archive(input) {
|
|
173
|
+
this.directory.archive(input);
|
|
174
|
+
if (this.relationship.getRow(input.slug))
|
|
175
|
+
this.relationship.archive(input);
|
|
176
|
+
return this.mutation(input.slug);
|
|
177
|
+
}
|
|
178
|
+
restore(input) {
|
|
179
|
+
this.directory.restore(input);
|
|
180
|
+
if (this.relationship.getRow(input.slug))
|
|
181
|
+
this.relationship.restore(input);
|
|
182
|
+
return this.mutation(input.slug);
|
|
183
|
+
}
|
|
184
|
+
/** Revisions from both halves, newest first, each saying which half and which agent. */
|
|
185
|
+
history(slug) {
|
|
186
|
+
return [
|
|
187
|
+
...this.directory.history(slug).map((r) => ({ ...r, store: 'directory' })),
|
|
188
|
+
...this.relationship.history(slug).map((r) => ({ ...r, store: 'relationship' })),
|
|
189
|
+
].sort((a, b) => (a.replaced_at < b.replaced_at ? 1 : a.replaced_at > b.replaced_at ? -1 : 0));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Restore one half to a revision: `store` says which (the relationship,
|
|
193
|
+
* unless named). A relationship revision from before the split holds the
|
|
194
|
+
* whole old card; only its relationship half comes back, so handles and
|
|
195
|
+
* facts never return to the agent's own store.
|
|
196
|
+
*/
|
|
197
|
+
revisionRestore(input) {
|
|
198
|
+
if (input.store === 'directory') {
|
|
199
|
+
this.directory.revisionRestore(input);
|
|
200
|
+
return this.mutation(input.slug);
|
|
201
|
+
}
|
|
202
|
+
const snap = this.relationship.revisionSnapshot(input.revision_id);
|
|
203
|
+
if (!snap)
|
|
204
|
+
throw new PersonCardError(`revision ${input.revision_id} not found`);
|
|
205
|
+
if (snap.slug !== input.slug)
|
|
206
|
+
throw new PersonCardError(`revision ${input.revision_id} belongs to another card, not ${input.slug}`);
|
|
207
|
+
if (!holdsDirectoryFields(snap.card)) {
|
|
208
|
+
this.relationship.revisionRestore(input);
|
|
209
|
+
return this.mutation(input.slug);
|
|
210
|
+
}
|
|
211
|
+
// The same gate the plain restore path has: no restoring onto an archived card.
|
|
212
|
+
const current = this.relationship.getRow(input.slug);
|
|
213
|
+
if (!current)
|
|
214
|
+
throw new PersonCardError(`no card for ${input.slug}`);
|
|
215
|
+
const dirRow = this.directory.getRow(input.slug);
|
|
216
|
+
if (!dirRow)
|
|
217
|
+
throw new PersonCardError(`no card for ${input.slug}`);
|
|
218
|
+
if (current.archived_at !== null || dirRow.archived_at !== null) {
|
|
219
|
+
throw new PersonCardError(`card for ${input.slug} is archived; person_restore it before restoring a revision`);
|
|
220
|
+
}
|
|
221
|
+
this.relationship.replaceCard(input.slug, splitCard(snap.card).relationship, {
|
|
222
|
+
updated: new Date(this.now()).toISOString(), synthesized_at: snap.synthesized_at ?? null,
|
|
223
|
+
archived_at: current.archived_at, archived_by: current.archived_by, archive_reason: current.archive_reason,
|
|
224
|
+
});
|
|
225
|
+
return this.mutation(input.slug);
|
|
226
|
+
}
|
|
227
|
+
mutation(slug) {
|
|
228
|
+
const row = this.getRow(slug);
|
|
229
|
+
if (!row)
|
|
230
|
+
throw new PersonCardError(`no card for ${slug}`);
|
|
231
|
+
return { card: row.card, unchanged: false, revision_id: null, archived: row.archived_at !== null, updated: row.updated, synthesized_at: row.synthesized_at };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Open the people for a household agent: the commons directory plus the
|
|
236
|
+
* agent's relationships, both attributing revisions to the agent. A
|
|
237
|
+
* read-only open returns null when the directory doesn't exist yet.
|
|
238
|
+
*/
|
|
239
|
+
export function openHouseholdPeople(h, agent, contextDir, opts = {}) {
|
|
240
|
+
const dirPath = commonsPeoplePath(h);
|
|
241
|
+
const relPath = resolveSqliteDbPath(contextDir);
|
|
242
|
+
if (opts.readonly && (!existsSync(dirPath) || !existsSync(relPath)))
|
|
243
|
+
return null;
|
|
244
|
+
if (!opts.readonly)
|
|
245
|
+
mkdirSync(commonsDir(h), { recursive: true, mode: 0o700 });
|
|
246
|
+
return new HouseholdPeople(new PeopleStore({ dbPath: dirPath, readonly: opts.readonly, now: opts.now, actor: agent }), new PeopleStore({ dbPath: relPath, readonly: opts.readonly, now: opts.now, actor: agent }), opts.now);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Move an agent's whole cards into the household split: the directory half
|
|
250
|
+
* to the commons, the relationship half back in place (timestamps and
|
|
251
|
+
* archive state kept). Every card is checked first: merged back together it
|
|
252
|
+
* must render the same text it does today, or nothing is written. Refuses a
|
|
253
|
+
* slug the directory already has (merging a second agent's people is a later
|
|
254
|
+
* step). Returns how many cards moved.
|
|
255
|
+
*/
|
|
256
|
+
export function splitPeopleIntoCommons(h, agent, contextDir, nowMs = Date.now()) {
|
|
257
|
+
if (!existsSync(resolveSqliteDbPath(contextDir)))
|
|
258
|
+
return 0;
|
|
259
|
+
mkdirSync(commonsDir(h), { recursive: true, mode: 0o700 });
|
|
260
|
+
const rel = new PeopleStore({ dbPath: resolveSqliteDbPath(contextDir), actor: agent });
|
|
261
|
+
const dir = new PeopleStore({ dbPath: commonsPeoplePath(h), actor: agent });
|
|
262
|
+
try {
|
|
263
|
+
const plan = rel.rows().flatMap((r) => {
|
|
264
|
+
const halves = splitCard(r.card);
|
|
265
|
+
const there = dir.getRow(r.slug);
|
|
266
|
+
// Already split (a re-run): the agent's card holds no handles or facts
|
|
267
|
+
// and the directory has the person.
|
|
268
|
+
if (there && !holdsDirectoryFields(r.card))
|
|
269
|
+
return [];
|
|
270
|
+
if (there && canonicalJson(there.card) !== canonicalJson(halves.directory)) {
|
|
271
|
+
throw new PersonCardError(`${r.slug} is already in the directory with different details; merging another agent's card into it isn't automatic yet`);
|
|
272
|
+
}
|
|
273
|
+
const before = renderCard(r.card, nowMs);
|
|
274
|
+
const after = renderCard(mergeCard(halves.directory, halves.relationship), nowMs);
|
|
275
|
+
if (before !== after)
|
|
276
|
+
throw new PersonCardError(`${r.slug} would render differently after the split; nothing was moved`);
|
|
277
|
+
return [{ r, halves }];
|
|
278
|
+
});
|
|
279
|
+
for (const { r, halves } of plan) {
|
|
280
|
+
const keep = { updated: r.updated, archived_at: r.archived_at, archived_by: r.archived_by, archive_reason: r.archive_reason };
|
|
281
|
+
dir.replaceCard(r.slug, halves.directory, { ...keep, synthesized_at: null });
|
|
282
|
+
rel.replaceCard(r.slug, halves.relationship, { ...keep, synthesized_at: r.synthesized_at });
|
|
283
|
+
}
|
|
284
|
+
return plan.length;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
rel.close();
|
|
288
|
+
dir.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function holdsDirectoryFields(card) {
|
|
292
|
+
return Object.keys(card.where ?? {}).length > 0 || Object.keys(splitHow(card.how).facts).length > 0;
|
|
293
|
+
}
|
|
294
|
+
/** Whether an agent still holds directory fields (handles, facts) in its own cards: an unsplit store. */
|
|
295
|
+
export function hasUnsplitPeople(contextDir) {
|
|
296
|
+
if (!existsSync(resolveSqliteDbPath(contextDir)))
|
|
297
|
+
return false;
|
|
298
|
+
const rel = new PeopleStore({ dbPath: resolveSqliteDbPath(contextDir), readonly: true });
|
|
299
|
+
try {
|
|
300
|
+
return rel.rows().some((r) => holdsDirectoryFields(r.card));
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
rel.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -79,8 +79,12 @@ export interface PersonRevisionMeta {
|
|
|
79
79
|
id: number;
|
|
80
80
|
op: RevisionOp;
|
|
81
81
|
replaced_at: string;
|
|
82
|
+
/** The agent whose change this revision records (null before households). */
|
|
83
|
+
actor?: string | null;
|
|
84
|
+
/** In a household: which half of the card this revision is from. */
|
|
85
|
+
store?: 'directory' | 'relationship';
|
|
82
86
|
}
|
|
83
|
-
export type RevisionOp = 'write' | 'verify' | 'archive' | 'restore' | 'revision_restore';
|
|
87
|
+
export type RevisionOp = 'write' | 'verify' | 'archive' | 'restore' | 'revision_restore' | 'split';
|
|
84
88
|
/** Every rejection the people surface makes. The message names the path. */
|
|
85
89
|
export declare class PersonCardError extends Error {
|
|
86
90
|
constructor(message: string);
|
|
@@ -112,6 +116,16 @@ export declare function fieldState(env: WhereEnvelope, nowMs: number): FieldStat
|
|
|
112
116
|
export declare function renderCard(card: PersonCard, nowMs: number, opts?: {
|
|
113
117
|
worstCase?: boolean;
|
|
114
118
|
}): string;
|
|
119
|
+
interface RawRow {
|
|
120
|
+
slug: string;
|
|
121
|
+
name: string;
|
|
122
|
+
card_json: string;
|
|
123
|
+
archived_at: string | null;
|
|
124
|
+
archived_by: string | null;
|
|
125
|
+
archive_reason: string | null;
|
|
126
|
+
updated: string;
|
|
127
|
+
synthesized_at: string | null;
|
|
128
|
+
}
|
|
115
129
|
export interface PeopleStoreConfig {
|
|
116
130
|
dbPath: string;
|
|
117
131
|
/** Open read-only and never migrate. Reads on an unmigrated store see no cards. */
|
|
@@ -119,6 +133,8 @@ export interface PeopleStoreConfig {
|
|
|
119
133
|
busyTimeoutMs?: number;
|
|
120
134
|
/** Clock, for tests. */
|
|
121
135
|
now?: () => number;
|
|
136
|
+
/** Who is writing (an agent's name), recorded on every revision it makes. */
|
|
137
|
+
actor?: string;
|
|
122
138
|
}
|
|
123
139
|
/** Thrown when a stored row cannot be turned back into a card. */
|
|
124
140
|
export declare class UnreadableCardError extends Error {
|
|
@@ -127,6 +143,7 @@ export declare class UnreadableCardError extends Error {
|
|
|
127
143
|
export declare class PeopleStore {
|
|
128
144
|
private db;
|
|
129
145
|
private readonly now;
|
|
146
|
+
private readonly actor;
|
|
130
147
|
/** False on a read-only store whose tables do not exist yet. */
|
|
131
148
|
readonly migrated: boolean;
|
|
132
149
|
constructor(config: PeopleStoreConfig);
|
|
@@ -163,6 +180,24 @@ export declare class PeopleStore {
|
|
|
163
180
|
read(slug: string, includeArchived?: boolean): PersonReadResult;
|
|
164
181
|
list(includeArchived?: boolean): PersonListEntry[];
|
|
165
182
|
history(slug: string): PersonRevisionMeta[];
|
|
183
|
+
/**
|
|
184
|
+
* Put a card in place as is, keeping the row's timestamps and archive state
|
|
185
|
+
* (a migration, not an edit). Recorded as a 'split' revision when a row is
|
|
186
|
+
* replaced.
|
|
187
|
+
*/
|
|
188
|
+
replaceCard(slug: string, card: PersonCard, keep: Pick<RawRow, 'updated' | 'synthesized_at' | 'archived_at' | 'archived_by' | 'archive_reason'>): void;
|
|
189
|
+
/** The raw stored rows (timestamps and archive state included), for a migration. */
|
|
190
|
+
rows(): Array<{
|
|
191
|
+
slug: string;
|
|
192
|
+
card: PersonCard;
|
|
193
|
+
updated: string;
|
|
194
|
+
synthesized_at: string | null;
|
|
195
|
+
archived_at: string | null;
|
|
196
|
+
archived_by: string | null;
|
|
197
|
+
archive_reason: string | null;
|
|
198
|
+
}>;
|
|
199
|
+
/** This database's `user` memories about a person, newer than `since` (all of them when null). */
|
|
200
|
+
memoriesSince(slug: string, since: string | null): number;
|
|
166
201
|
/** A revision's snapshot (the whole previous row), for tests and inspection. */
|
|
167
202
|
revisionSnapshot(id: number): {
|
|
168
203
|
slug: string;
|
|
@@ -190,3 +225,4 @@ export interface RoomOptions {
|
|
|
190
225
|
* block; one bad row gives that slug the unreadable line and the rest render.
|
|
191
226
|
*/
|
|
192
227
|
export declare function roomBlock(contextDir: string, people: readonly string[] | undefined, opts?: RoomOptions): string | null;
|
|
228
|
+
export {};
|