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