@wirecat/cli-core 0.547.0 → 0.549.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/dist/calendar/cli/answers.d.ts +7 -2
  3. package/dist/calendar/cli/answers.js +12 -4
  4. package/dist/calendar/cli/index.d.ts +10 -1
  5. package/dist/calendar/cli/index.js +18 -11
  6. package/dist/calendar/cli/search-all.d.ts +17 -0
  7. package/dist/calendar/cli/search-all.js +72 -0
  8. package/dist/calendar/feeds.d.ts +19 -0
  9. package/dist/calendar/feeds.js +52 -9
  10. package/dist/calendar/source.d.ts +10 -2
  11. package/dist/calendar/sync.d.ts +2 -0
  12. package/dist/calendar/sync.js +10 -6
  13. package/dist/calendar/testing/memory.js +39 -0
  14. package/dist/cli/answer-entity.d.ts +4 -0
  15. package/dist/cli/answer-entity.js +5 -0
  16. package/dist/cli/bot/context.d.ts +2 -0
  17. package/dist/cli/compact.d.ts +41 -0
  18. package/dist/cli/compact.js +313 -0
  19. package/dist/cli/context.d.ts +11 -2
  20. package/dist/cli/context.js +15 -6
  21. package/dist/cli/envelope.d.ts +5 -1
  22. package/dist/cli/envelope.js +15 -4
  23. package/dist/cli/index.d.ts +2 -0
  24. package/dist/cli/index.js +2 -0
  25. package/dist/cli/messenger/search-command.d.ts +10 -0
  26. package/dist/cli/messenger/search-command.js +19 -13
  27. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  28. package/dist/cli/program.d.ts +3 -0
  29. package/dist/cli/program.js +7 -1
  30. package/dist/domain/providers.d.ts +3 -0
  31. package/dist/domain/providers.js +20 -4
  32. package/dist/domain/ref-arguments.js +3 -7
  33. package/dist/domain/ref-summaries.d.ts +56 -0
  34. package/dist/domain/ref-summaries.js +63 -0
  35. package/dist/entities/index.d.ts +11 -0
  36. package/dist/entities/relationship.d.ts +2 -0
  37. package/dist/entities/relationship.js +2 -0
  38. package/dist/entities/task-results.d.ts +10 -0
  39. package/dist/entities/task-results.js +1 -0
  40. package/dist/errors/reasons.js +1 -0
  41. package/dist/meetings/event-store.d.ts +10 -0
  42. package/dist/meetings/testing/event-store-contract.js +36 -0
  43. package/dist/output-detail.js +9 -5
  44. package/dist/output.d.ts +5 -1
  45. package/dist/output.js +29 -4
  46. package/dist/services/proposals/kinds/relationship-update.d.ts +4 -0
  47. package/dist/services/proposals/kinds/relationship-update.js +53 -0
  48. package/dist/services/proposals/registry.js +2 -0
  49. package/dist/store/decisions.d.ts +3 -1
  50. package/dist/store/decisions.js +7 -1
  51. package/dist/store/index.d.ts +2 -1
  52. package/dist/store/sqlite/accounts.d.ts +8 -0
  53. package/dist/store/sqlite/accounts.js +2 -2
  54. package/dist/store/sqlite/calendar-forget.d.ts +17 -0
  55. package/dist/store/sqlite/calendar-forget.js +68 -0
  56. package/dist/store/sqlite/calendar.js +8 -0
  57. package/dist/store/sqlite/evidence-sources.d.ts +2 -2
  58. package/dist/store/sqlite/evidence-sources.js +41 -77
  59. package/dist/store/sqlite/knowledge.d.ts +17 -23
  60. package/dist/store/sqlite/knowledge.js +13 -13
  61. package/dist/store/sqlite/manifest.js +2 -0
  62. package/dist/store/sqlite/migrations.generated.js +12 -0
  63. package/dist/store/sqlite/notes.d.ts +5 -0
  64. package/dist/store/sqlite/notes.js +6 -2
  65. package/dist/store/sqlite/open-store.js +2 -0
  66. package/dist/store/sqlite/proposed-actions.d.ts +1 -1
  67. package/dist/store/sqlite/proposed-actions.js +10 -2
  68. package/dist/store/sqlite/ref-summaries.d.ts +17 -0
  69. package/dist/store/sqlite/ref-summaries.js +378 -0
  70. package/dist/store/sqlite/relation-proposals.d.ts +40 -0
  71. package/dist/store/sqlite/relation-proposals.js +88 -1
  72. package/dist/store/sqlite/schema.d.ts +16 -0
  73. package/dist/store/sqlite/schema.js +1 -0
  74. package/dist/store/sqlite/suggested-links.d.ts +10 -0
  75. package/dist/store/sqlite/suggested-links.js +68 -0
  76. package/dist/store/store.d.ts +3 -0
  77. package/dist/store/store.js +5 -0
  78. package/package.json +3 -3
  79. package/parity.json +4 -0
@@ -0,0 +1,378 @@
1
+ import { filesAccountSql, providerLabel, providerNamed } from "../../domain/providers.js";
2
+ import { oneLine } from "../../domain/ref-summaries.js";
3
+ import { formatReference, parseReference } from "../../domain/references.js";
4
+ const EXCERPT_CHARS = 280;
5
+ const TITLE_CHARS = 120;
6
+ const MEMORY_TITLE_CHARS = 80;
7
+ /** SQLite's parameter limit is per statement; a long page is read in slices, still one statement per slice. */
8
+ const SLICE = 500;
9
+ const flat = (value) => {
10
+ if (value == null)
11
+ return null;
12
+ const text = oneLine(String(value));
13
+ return text === "" ? null : text;
14
+ };
15
+ const cut = (text, chars) => text === null || text.length <= chars ? text : `${text.slice(0, chars - 1)}…`;
16
+ const iso = (at) => (at === null ? null : new Date(at).toISOString());
17
+ const minute = (at) => iso(at)?.slice(0, 16).replace("T", " ") ?? null;
18
+ const storeId = (text) => (/^[1-9]\d{0,15}$/.test(text) ? Number(text) : undefined);
19
+ const bodyOr = (body, column) => (body ? column : "NULL");
20
+ const PERSON_NAME = "coalesce(p.name, (SELECT a.name FROM aliases a WHERE a.aliasable_type = 'person' AND a.aliasable_id = p.id " +
21
+ "ORDER BY a.display DESC, a.id LIMIT 1), (SELECT i.name FROM identity_links l JOIN identities i " +
22
+ "ON i.id = l.identity_id WHERE l.person_id = p.id AND i.name IS NOT NULL ORDER BY i.id LIMIT 1))";
23
+ /** What a row is about, by name; the owner is left out, or every owner's note would say whose it is. */
24
+ const subjectName = (type, id) => `CASE ${type} WHEN 'person' THEN (SELECT ${PERSON_NAME} FROM persons p WHERE p.id = ${id} AND p.owner = 0) ` +
25
+ `WHEN 'project' THEN (SELECT name FROM projects WHERE id = ${id}) ` +
26
+ `WHEN 'organization' THEN (SELECT name FROM organizations WHERE id = ${id}) ` +
27
+ `WHEN 'account' THEN (SELECT name FROM accounts WHERE id = ${id}) END`;
28
+ const select = (columns) => `SELECT w.n, ${["title", "holder", "sender", "at", "provider", "account", "deleted", "body"]
29
+ .map((column) => `${columns[column] ?? "NULL"} AS ${column}`)
30
+ .join(", ")}`;
31
+ const byId = (kind, table, columns) => ({
32
+ kind,
33
+ sql: (body) => `${select(columns(body))} FROM w JOIN ${table} x ON x.id = w.k1`,
34
+ });
35
+ const READERS = {
36
+ message: {
37
+ kind: "message",
38
+ sql: (body) => `${select({
39
+ holder: "c.title",
40
+ sender: "coalesce(m.sender_name, i.name)",
41
+ at: "m.sent_at",
42
+ provider: "a.provider",
43
+ account: "a.external_id",
44
+ deleted: "m.deleted_at",
45
+ body: bodyOr(body, "m.text"),
46
+ })} FROM w JOIN accounts a ON a.provider = w.k1 AND a.external_id = w.k2 ` +
47
+ "JOIN chats c ON c.account_id = a.id AND c.external_id = w.k3 " +
48
+ "JOIN messages m ON m.chat_id = c.id AND m.external_id = w.k4 LEFT JOIN identities i ON i.id = m.sender_identity_id",
49
+ },
50
+ email: {
51
+ kind: "email",
52
+ sql: (body) => `${select({
53
+ title: "e.subject",
54
+ holder: "coalesce(e.from_name, e.from_address)",
55
+ sender: "coalesce(e.from_name, e.from_address)",
56
+ at: "e.sent_at",
57
+ provider: "'email'",
58
+ account: "a.external_id",
59
+ deleted: "e.deleted_at",
60
+ body: bodyOr(body, "coalesce(e.body_text, e.snippet)"),
61
+ })} FROM w JOIN accounts a ON a.provider = 'email' AND a.external_id = w.k1 ` +
62
+ "JOIN email_threads t ON t.account_id = a.id AND t.external_id = w.k2 " +
63
+ "JOIN emails e ON e.email_thread_id = t.id AND e.external_id = w.k3",
64
+ },
65
+ chat: {
66
+ kind: "chat",
67
+ sql: () => `${select({
68
+ title: "c.title",
69
+ at: "(SELECT max(sent_at) FROM messages WHERE chat_id = c.id)",
70
+ provider: "a.provider",
71
+ account: "a.external_id",
72
+ })} FROM w JOIN accounts a ON a.provider = w.k1 AND a.external_id = w.k2 ` +
73
+ "JOIN chats c ON c.account_id = a.id AND c.external_id = w.k3",
74
+ },
75
+ email_thread: {
76
+ kind: "email_thread",
77
+ sql: () => `${select({
78
+ title: "t.subject",
79
+ holder: "a.external_id",
80
+ at: "t.last_email_at",
81
+ provider: "'email'",
82
+ account: "a.external_id",
83
+ deleted: "t.deleted_at",
84
+ })} FROM w JOIN accounts a ON a.provider = 'email' AND a.external_id = w.k1 ` +
85
+ "JOIN email_threads t ON t.account_id = a.id AND t.external_id = w.k2",
86
+ },
87
+ contact: {
88
+ kind: "contact",
89
+ sql: () => `${select({ title: "coalesce(i.name, '@' || i.username)", provider: "i.provider" })} ` +
90
+ "FROM w JOIN identities i ON i.provider = w.k1 AND i.external_id = w.k2",
91
+ },
92
+ folder: {
93
+ kind: "folder",
94
+ sql: () => `${select({ title: "a.name", provider: "a.provider", account: "a.external_id" })} ` +
95
+ `FROM w JOIN accounts a ON a.id = w.k1 AND ${filesAccountSql("a.provider")}`,
96
+ },
97
+ task: {
98
+ kind: "task",
99
+ // A task moved to another project took a new key there; its old key still names it, as in `taskRowOf`.
100
+ sql: (body) => `${select({
101
+ title: "CASE WHEN x.key IS NULL THEN x.title ELSE x.key || ' · ' || x.title END",
102
+ holder: "p.name",
103
+ at: "x.due_at",
104
+ deleted: "x.deleted_at",
105
+ body: bodyOr(body, "x.description"),
106
+ })} FROM w JOIN tasks x ON x.id = coalesce(` +
107
+ "(SELECT id FROM tasks WHERE package_id = w.k1 OR key = w.k1 ORDER BY id LIMIT 1), " +
108
+ "(SELECT task_id FROM task_events WHERE kind = 'project' AND json_extract(changes, '$.key[0]') = w.k1 " +
109
+ "ORDER BY id DESC LIMIT 1)) LEFT JOIN projects p ON p.id = x.project_id",
110
+ },
111
+ meeting: {
112
+ kind: "meeting",
113
+ sql: (body) => `${select({
114
+ title: "m.title",
115
+ holder: "coalesce(a.name, a.external_id)",
116
+ sender: "r.speaker_name",
117
+ at: "m.started_at + coalesce(r.start_ms, 0)",
118
+ provider: "a.provider",
119
+ account: "a.external_id",
120
+ deleted: "coalesce(m.deleted_at, t.deleted_at)",
121
+ body: bodyOr(body, "CASE WHEN w.k4 IS NOT NULL THEN r.text WHEN w.k3 IS NULL THEN m.description END"),
122
+ })} FROM w JOIN meetings m ON m.id = w.k2 AND m.account_id = w.k1 LEFT JOIN accounts a ON a.id = m.account_id ` +
123
+ "LEFT JOIN meeting_transcripts t ON t.id = w.k3 AND t.meeting_id = m.id " +
124
+ "LEFT JOIN meeting_transcript_rows r ON r.meeting_transcript_id = t.id AND r.position = w.k4 " +
125
+ "WHERE (w.k3 IS NULL OR t.id IS NOT NULL) AND (w.k4 IS NULL OR r.id IS NOT NULL)",
126
+ },
127
+ event: {
128
+ kind: "event",
129
+ sql: (body) => `${select({
130
+ title: "x.title",
131
+ holder: "c.title",
132
+ at: "x.starts_at",
133
+ provider: "a.provider",
134
+ account: "a.external_id",
135
+ deleted: "x.deleted_at",
136
+ body: bodyOr(body, "x.description"),
137
+ })} FROM w JOIN events x ON x.id = w.k1 ` +
138
+ "LEFT JOIN calendar_entries ce ON ce.id = (SELECT min(id) FROM calendar_entries WHERE event_id = x.id) " +
139
+ "LEFT JOIN calendars c ON c.id = ce.calendar_id LEFT JOIN accounts a ON a.id = c.account_id",
140
+ },
141
+ document: {
142
+ kind: "document",
143
+ sql: (body) => `${select({
144
+ title: "coalesce(x.title, x.file_name)",
145
+ at: "coalesce(x.external_updated_at, x.updated_at)",
146
+ provider: "a.provider",
147
+ account: "a.external_id",
148
+ deleted: "x.deleted_at",
149
+ body: bodyOr(body, "x.body"),
150
+ })} FROM w JOIN documents x ON x.id = w.k1 LEFT JOIN accounts a ON a.id = x.account_id`,
151
+ },
152
+ note: byId("note", "notes", (body) => ({
153
+ title: "x.title",
154
+ holder: subjectName("x.notable_type", "x.notable_id"),
155
+ at: "x.updated_at",
156
+ provider: "'notes'",
157
+ deleted: "x.deleted_at",
158
+ body: bodyOr(body, "x.body"),
159
+ })),
160
+ person: {
161
+ kind: "person",
162
+ sql: () => `${select({ title: PERSON_NAME })} FROM w JOIN persons p ON p.id = w.k1`,
163
+ },
164
+ organization: byId("organization", "organizations", () => ({ title: "x.name", deleted: "x.deleted_at" })),
165
+ project: byId("project", "projects", () => ({
166
+ title: "CASE WHEN x.key IS NULL THEN x.name ELSE x.key || ' · ' || x.name END",
167
+ deleted: "x.deleted_at",
168
+ })),
169
+ memory: byId("memory", "memories", (body) => ({
170
+ title: "substr(x.body, 1, 400)",
171
+ holder: subjectName("x.subject_type", "x.subject_id"),
172
+ at: "x.created_at",
173
+ deleted: "x.deleted_at",
174
+ body: bodyOr(body, "x.body"),
175
+ })),
176
+ fact: byId("fact", "facts", (body) => ({
177
+ title: "substr(x.statement, 1, 400)",
178
+ holder: subjectName("x.subject_type", "x.subject_id"),
179
+ at: "x.created_at",
180
+ deleted: "x.deleted_at",
181
+ body: bodyOr(body, "x.statement"),
182
+ })),
183
+ bot: byId("bot", "bots", () => ({ title: "x.name" })),
184
+ };
185
+ /** Mail saved before the mail tables existed is still messages, so a miss there is read again as a message. */
186
+ const FALLBACK = { email: "message", email_thread: "chat" };
187
+ const route = (reference) => {
188
+ switch (reference.type) {
189
+ case "message":
190
+ return reference.provider === "email"
191
+ ? { reader: "email", keys: [reference.account, reference.chat, reference.message] }
192
+ : { reader: "message", keys: [reference.provider, reference.account, reference.chat, reference.message] };
193
+ case "chat":
194
+ return reference.provider === "email"
195
+ ? { reader: "email_thread", keys: [reference.account, reference.chat] }
196
+ : { reader: "chat", keys: [reference.provider, reference.account, reference.chat] };
197
+ case "contact":
198
+ return { reader: "contact", keys: [reference.provider, reference.id] };
199
+ case "meeting":
200
+ return {
201
+ reader: "meeting",
202
+ keys: [reference.accountId, reference.meetingId, reference.transcriptId ?? null, reference.cuePosition ?? null],
203
+ };
204
+ case "task":
205
+ return { reader: "task", keys: [reference.id] };
206
+ case "entity":
207
+ return undefined;
208
+ case "folder": {
209
+ const id = storeId(reference.id);
210
+ return id === undefined ? undefined : { reader: "folder", keys: [id] };
211
+ }
212
+ default: {
213
+ const id = storeId(reference.id);
214
+ return id === undefined ? undefined : { reader: reference.type, keys: [id] };
215
+ }
216
+ }
217
+ };
218
+ const keysOf = (reader, reference) => {
219
+ if (reader === "message" && reference.type === "message")
220
+ return [reference.provider, reference.account, reference.chat, reference.message];
221
+ if (reader === "chat" && reference.type === "chat")
222
+ return [reference.provider, reference.account, reference.chat];
223
+ return [];
224
+ };
225
+ const kindOfReference = (reference) => reference.type === "entity"
226
+ ? "unknown"
227
+ : reference.type === "message" && reference.provider === "email"
228
+ ? "email"
229
+ : reference.type === "chat" && reference.provider === "email"
230
+ ? "email_thread"
231
+ : reference.type;
232
+ const runReader = (database, reader, wanted, body) => {
233
+ const found = new Map();
234
+ const width = wanted[0]?.keys.length ?? 0;
235
+ const names = Array.from({ length: width }, (_, at) => `k${at + 1}`);
236
+ for (let start = 0; start < wanted.length; start += SLICE) {
237
+ const slice = wanted.slice(start, start + SLICE);
238
+ const values = slice.map(() => `(${["?", ...names.map(() => "?")].join(", ")})`).join(", ");
239
+ const sql = `WITH w(n, ${names.join(", ")}) AS (VALUES ${values}) ${reader.sql(body)}`;
240
+ const parameters = slice.flatMap((one, at) => [start + at, ...one.keys]);
241
+ for (const row of database.prepare(sql).all(...parameters)) {
242
+ const n = Number(row.n);
243
+ if (found.has(n))
244
+ continue;
245
+ found.set(n, {
246
+ title: flat(row.title),
247
+ holder: flat(row.holder),
248
+ sender: flat(row.sender),
249
+ at: row.at == null ? null : Number(row.at),
250
+ provider: row.provider == null ? null : String(row.provider),
251
+ account: row.account == null ? null : String(row.account),
252
+ deleted: row.deleted != null,
253
+ body: row.body == null ? null : String(row.body),
254
+ });
255
+ }
256
+ }
257
+ return found;
258
+ };
259
+ const toolOf = (provider) => (provider === null ? null : (providerNamed(provider)?.command ?? null));
260
+ const refOfAccount = (reference) => reference.type === "message" || reference.type === "chat"
261
+ ? { provider: reference.provider, account: reference.account }
262
+ : reference.type === "contact"
263
+ ? { provider: reference.provider, account: null }
264
+ : { provider: null, account: null };
265
+ const titleOf = (kind, reference, found) => {
266
+ switch (kind) {
267
+ case "message": {
268
+ const words = [found.sender, minute(found.at)].filter((one) => one !== null);
269
+ return words.length === 0 ? null : words.join(" · ");
270
+ }
271
+ case "memory":
272
+ case "fact":
273
+ return cut(found.title, MEMORY_TITLE_CHARS);
274
+ case "folder":
275
+ return reference.type === "folder" && reference.path !== null && found.title !== null
276
+ ? `${found.title}/${reference.path}`
277
+ : found.title;
278
+ default:
279
+ return found.title;
280
+ }
281
+ };
282
+ const inOf = (kind, found) => found.holder ??
283
+ ((kind === "chat" || kind === "contact" || kind === "document") && found.provider !== null
284
+ ? providerLabel(found.provider)
285
+ : null);
286
+ const readOf = (wanted, found, reader, excerpt) => {
287
+ const ref = formatReference(wanted.reference);
288
+ if (found === undefined || reader === undefined) {
289
+ const { provider, account } = refOfAccount(wanted.reference);
290
+ return {
291
+ ref,
292
+ kind: kindOfReference(wanted.reference),
293
+ title: null,
294
+ in: null,
295
+ at: null,
296
+ provider,
297
+ account,
298
+ tool: toolOf(provider),
299
+ state: "unavailable",
300
+ sender: null,
301
+ excerpt: null,
302
+ };
303
+ }
304
+ const state = found.deleted ? "deleted" : "available";
305
+ return {
306
+ ref,
307
+ kind: reader.kind,
308
+ title: cut(titleOf(reader.kind, wanted.reference, found), TITLE_CHARS),
309
+ in: cut(inOf(reader.kind, found), TITLE_CHARS),
310
+ at: iso(found.at),
311
+ provider: found.provider,
312
+ account: found.account,
313
+ tool: toolOf(found.provider),
314
+ state,
315
+ sender: found.sender,
316
+ excerpt: excerpt && state === "available" ? cut(flat(found.body), EXCERPT_CHARS) : null,
317
+ };
318
+ };
319
+ const unknown = (text) => ({
320
+ ref: text.trim(),
321
+ kind: "unknown",
322
+ title: null,
323
+ in: null,
324
+ at: null,
325
+ provider: null,
326
+ account: null,
327
+ tool: null,
328
+ state: "unavailable",
329
+ sender: null,
330
+ excerpt: null,
331
+ });
332
+ const parsed = (text) => {
333
+ try {
334
+ return parseReference(text);
335
+ }
336
+ catch {
337
+ return undefined;
338
+ }
339
+ };
340
+ /**
341
+ * Every ref read back in one statement per kind present, keyed by the text passed and in its order. A bad ref is
342
+ * `unknown` and a ref the store lacks is `unavailable`: one bad row must not fail a page of them.
343
+ */
344
+ export const refReadsOf = (database, texts, { excerpt = false } = {}) => {
345
+ const reads = new Map();
346
+ const groups = new Map();
347
+ for (const text of new Set(texts)) {
348
+ const reference = parsed(text);
349
+ const routed = reference === undefined ? undefined : route(reference);
350
+ if (reference === undefined || routed === undefined) {
351
+ reads.set(text, reference === undefined ? unknown(text) : readOf({ text, reference, keys: [] }, undefined, undefined, false));
352
+ continue;
353
+ }
354
+ reads.set(text, unknown(text));
355
+ const group = groups.get(routed.reader) ?? [];
356
+ group.push({ text, reference, keys: routed.keys });
357
+ groups.set(routed.reader, group);
358
+ }
359
+ const pending = [...groups.entries()];
360
+ while (pending.length > 0) {
361
+ const [name, wanted] = pending.shift();
362
+ const reader = READERS[name];
363
+ const found = runReader(database, reader, wanted, excerpt);
364
+ const fallback = FALLBACK[name];
365
+ const missed = [];
366
+ for (const [n, one] of wanted.entries()) {
367
+ const row = found.get(n);
368
+ if (row === undefined && fallback !== undefined)
369
+ missed.push(one);
370
+ reads.set(one.text, readOf(one, row, reader, excerpt));
371
+ }
372
+ if (fallback !== undefined && missed.length > 0)
373
+ pending.push([fallback, missed.map((one) => ({ ...one, keys: keysOf(fallback, one.reference) }))]);
374
+ }
375
+ return reads;
376
+ };
377
+ const summaryOf = ({ sender, excerpt, ...summary }, withExcerpt) => withExcerpt ? { ...summary, excerpt } : summary;
378
+ export const refSummariesOf = (database, texts, options = {}) => new Map([...refReadsOf(database, texts, options)].map(([text, read]) => [text, summaryOf(read, options.excerpt === true)]));
@@ -1,9 +1,35 @@
1
1
  import { type Decider } from "../decisions.js";
2
+ import type { RELATION_KINDS } from "./link-kinds.js";
2
3
  import type { Author } from "./memories.js";
3
4
  import { type Link } from "./notes.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  import { type ProposedAction } from "./proposed-actions.js";
6
7
  import { type Thing } from "./things.js";
8
+ export interface KnowledgeRelation {
9
+ id: string;
10
+ from: string;
11
+ to: string;
12
+ kind: (typeof RELATION_KINDS)[number];
13
+ role: string | null;
14
+ /** What a chat is for in the project, from the purposes memo's config lists; `null` means `work`. */
15
+ purpose: string | null;
16
+ evidence: string | null;
17
+ confirmed: boolean;
18
+ provenance: string | null;
19
+ confidenceCategory: "owner-confirmed" | "weak";
20
+ createdAt: string;
21
+ }
22
+ export interface RelateInput {
23
+ from: string;
24
+ to: string;
25
+ kind: KnowledgeRelation["kind"];
26
+ role?: string;
27
+ purpose?: string;
28
+ evidence?: string;
29
+ confirmed?: boolean;
30
+ provenance?: string;
31
+ }
32
+ export declare const relationOf: (link: Link) => KnowledgeRelation;
7
33
  /** `relationship` proposals in `proposed_actions`: an agent's or a rule's, and the rejected ones a scan skips. */
8
34
  export declare const relationProposalsOver: (context: StoreContext) => {
9
35
  /** The same pending pair returns the proposal already held. */
@@ -12,10 +38,19 @@ export declare const relationProposalsOver: (context: StoreContext) => {
12
38
  to: string;
13
39
  kind: string;
14
40
  role?: string;
41
+ purpose?: string;
15
42
  evidence?: string[];
16
43
  reason?: string;
17
44
  }, by: Exclude<Author, "owner">) => ProposedAction;
18
45
  rejected: (link: Link, by: Decider, reason?: string) => void;
46
+ update: (link: Link, input: RelationUpdate) => Link;
47
+ proposeUpdate: (link: Link, input: RelationUpdate & {
48
+ reason?: string;
49
+ }, by: Exclude<Author, "owner">) => ProposedAction;
50
+ decideUpdate: (id: string, decision: LinkDecision) => {
51
+ proposal: ProposedAction;
52
+ link: Link | null;
53
+ };
19
54
  /**
20
55
  * Decides a stored `relationship` or `key_document` proposal in one transaction: the confirmed link and the
21
56
  * decision land together or not at all.
@@ -27,6 +62,11 @@ export declare const relationProposalsOver: (context: StoreContext) => {
27
62
  /** Decides an unconfirmed link written before proposals were stored; only the owner may, as its author is unknown. */
28
63
  decideLink: (id: string, decision: LinkDecision) => Link | null;
29
64
  };
65
+ /** A relationship's role and purpose to set; `null` clears one. */
66
+ export interface RelationUpdate {
67
+ role?: string | null;
68
+ purpose?: string | null;
69
+ }
30
70
  export interface LinkDecision {
31
71
  confirm: boolean;
32
72
  by: Decider;
@@ -1,10 +1,23 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { assertMayDecide, deciderAuthor } from "../decisions.js";
3
3
  import { actorOfOrigin, botNamed, ownerPerson } from "./actors.js";
4
- import { insertLink, linkOf } from "./notes.js";
4
+ import { insertLink, linkOf, purposeOf } from "./notes.js";
5
5
  import { proposalOperationsOver, proposerOfAction } from "./proposed-actions.js";
6
6
  import { thingOf } from "./things.js";
7
7
  import { write } from "./transaction.js";
8
+ export const relationOf = (link) => ({
9
+ id: link.id,
10
+ from: link.from,
11
+ to: String(link.to),
12
+ kind: link.kind,
13
+ role: link.role,
14
+ purpose: link.purpose,
15
+ evidence: link.evidence,
16
+ confirmed: link.confirmed,
17
+ provenance: link.provenance,
18
+ confidenceCategory: link.confirmed ? "owner-confirmed" : "weak",
19
+ createdAt: link.createdAt,
20
+ });
8
21
  /** `relationship` proposals in `proposed_actions`: an agent's or a rule's, and the rejected ones a scan skips. */
9
22
  export const relationProposalsOver = (context) => {
10
23
  const { database, now } = context;
@@ -26,6 +39,14 @@ export const relationProposalsOver = (context) => {
26
39
  "VALUES ('relationship', ?, ?, ?, ?, 'rejected', ?, ?, ?, ?, ?, ?, ?, ?)")
27
40
  .run(from?.type ?? null, from?.id ?? null, JSON.stringify({ from: link.from, to: link.to, kind: link.kind, ...(link.role ? { role: link.role } : {}) }), link.evidence, proposed.type, proposed.id, decided.type, decided.id, at, JSON.stringify({ reason: why.join(": "), link: link.id }), at, at);
28
41
  };
42
+ const applyUpdate = (link, input) => {
43
+ const changes = changesOf(input);
44
+ database
45
+ .prepare("UPDATE links SET role = CASE WHEN ? THEN ? ELSE role END, purpose = CASE WHEN ? THEN ? ELSE purpose END, " +
46
+ "updated_at = ? WHERE id = ?")
47
+ .run(Number("role" in changes), changes.role ?? null, Number("purpose" in changes), changes.purpose ?? null, now(), Number(link.id));
48
+ return linkOf(database, database.prepare("SELECT * FROM links WHERE id = ?").get(Number(link.id)));
49
+ };
29
50
  return {
30
51
  /** The same pending pair returns the proposal already held. */
31
52
  propose: (from, input, by) => {
@@ -48,6 +69,7 @@ export const relationProposalsOver = (context) => {
48
69
  to: input.to,
49
70
  kind: input.kind,
50
71
  ...(input.role === undefined ? {} : { role: input.role }),
72
+ ...(input.purpose === undefined ? {} : { purpose: purposeOf(input.purpose) }),
51
73
  evidence,
52
74
  },
53
75
  ...(input.reason === undefined ? {} : { reason: input.reason }),
@@ -55,6 +77,53 @@ export const relationProposalsOver = (context) => {
55
77
  });
56
78
  },
57
79
  rejected: (link, by, reason) => rejected(link, by, reason),
80
+ update: (link, input) => write(database, () => applyUpdate(link, input)),
81
+ proposeUpdate: (link, input, by) => {
82
+ const changes = changesOf(input);
83
+ return write(database, () => {
84
+ const from = thingOf(database, link.from);
85
+ const held = database
86
+ .prepare("SELECT id FROM proposed_actions WHERE kind='relationship_update' AND status='proposed' AND target_type=? " +
87
+ "AND target_id=? AND json_extract(payload,'$.relationship')=? AND json_extract(payload,'$.changes')=json(?) " +
88
+ "ORDER BY id LIMIT 1")
89
+ .get(from?.type ?? null, from?.id ?? null, link.id, JSON.stringify(changes));
90
+ if (held)
91
+ return operations.get(String(held.id));
92
+ return operations.propose({
93
+ kind: "relationship_update",
94
+ by,
95
+ target: link.from,
96
+ payload: {
97
+ relationship: link.id,
98
+ from: link.from,
99
+ to: link.to,
100
+ kind: link.kind,
101
+ changes,
102
+ before: { role: link.role, purpose: link.purpose },
103
+ },
104
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
105
+ });
106
+ });
107
+ },
108
+ decideUpdate: (id, decision) => write(database, () => {
109
+ const proposal = operations.get(id);
110
+ if (proposal.kind !== "relationship_update")
111
+ throw new CliError("validation_error", `${id} is a ${proposal.kind} proposal, not a relationship change`);
112
+ assertMayDecide({ id, proposedBy: proposerOfAction(proposal) }, decision.by, context.decisions);
113
+ const author = deciderAuthor(decision.by);
114
+ const reason = decision.reason?.trim() || undefined;
115
+ if (!decision.confirm)
116
+ return { proposal: operations.reject(id, author, reason === undefined ? undefined : { reason }), link: null };
117
+ const payload = proposal.payload;
118
+ const row = database.prepare("SELECT * FROM links WHERE id = ?").get(Number(payload.relationship));
119
+ if (!row)
120
+ throw new CliError("not_found", `relationship ${payload.relationship} is gone`);
121
+ const link = applyUpdate(linkOf(database, row), payload.changes);
122
+ return {
123
+ proposal: operations.approve(id, { relationship: link.id, ...(reason === undefined ? {} : { reason }) }, author),
124
+ link,
125
+ };
126
+ }),
58
127
  /**
59
128
  * Decides a stored `relationship` or `key_document` proposal in one transaction: the confirmed link and the
60
129
  * decision land together or not at all.
@@ -75,6 +144,7 @@ export const relationProposalsOver = (context) => {
75
144
  to: String(key ? payload.project : payload.to),
76
145
  kind: key ? "related-to" : (payload.kind ?? "member-of"),
77
146
  ...(key ? { role: "key" } : payload.role === undefined ? {} : { role: payload.role }),
147
+ ...(key || payload.purpose === undefined ? {} : { purpose: payload.purpose }),
78
148
  ...(proposal.reason === null ? {} : { evidence: proposal.reason }),
79
149
  ...decidedOrigin(decision.by),
80
150
  });
@@ -104,6 +174,23 @@ export const relationProposalsOver = (context) => {
104
174
  }),
105
175
  };
106
176
  };
177
+ /** What a relationship update sets, checked; `null` clears a field and an absent one is left alone. */
178
+ const changesOf = (input) => {
179
+ const changes = {};
180
+ if (input.role !== undefined)
181
+ changes.role = input.role === null ? null : roleOf(input.role);
182
+ if (input.purpose !== undefined)
183
+ changes.purpose = input.purpose === null ? null : purposeOf(input.purpose);
184
+ if (!Object.keys(changes).length)
185
+ throw new CliError("validation_error", "a relationship update sets a role, a purpose or both");
186
+ return changes;
187
+ };
188
+ const roleOf = (role) => {
189
+ const value = role.trim();
190
+ if (!value || value.length > 200)
191
+ throw new CliError("validation_error", "a role takes 1–200 characters");
192
+ return value;
193
+ };
107
194
  /** A link an agent confirmed for the owner says so in its provenance. */
108
195
  const decidedOrigin = (by) => by === "owner"
109
196
  ? { origin: "owner" }
@@ -14757,6 +14757,22 @@ export declare const links: import("drizzle-orm/sqlite-core").SQLiteTableWithCol
14757
14757
  identity: undefined;
14758
14758
  generated: undefined;
14759
14759
  }, {}>;
14760
+ purpose: import("drizzle-orm/sqlite-core").SQLiteColumn<{
14761
+ name: string;
14762
+ tableName: "links";
14763
+ dataType: "string";
14764
+ data: string;
14765
+ driverParam: string;
14766
+ notNull: false;
14767
+ hasDefault: false;
14768
+ isPrimaryKey: false;
14769
+ isAutoincrement: false;
14770
+ hasRuntimeDefault: false;
14771
+ enumValues: [string, ...string[]];
14772
+ baseColumn: never;
14773
+ identity: undefined;
14774
+ generated: undefined;
14775
+ }, {}>;
14760
14776
  };
14761
14777
  dialect: 'sqlite';
14762
14778
  isAlias: false;
@@ -1475,6 +1475,7 @@ export const links = sqliteTable("links", {
1475
1475
  createdAt: integer("created_at").notNull(),
1476
1476
  author: text("author"),
1477
1477
  updatedAt: integer("updated_at").notNull(),
1478
+ purpose: text("purpose"),
1478
1479
  }, (table) => [
1479
1480
  index("links_unresolved").on(table.targetFolded).where(sql `to_id IS NULL`),
1480
1481
  index("links_by_from_type_from_id").on(table.fromType, table.fromId),
@@ -0,0 +1,10 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** Set by store version 44 and deleted once the move ran. */
3
+ export declare const SUGGESTED_LINKS_TO_MOVE = "suggested_links_to_move";
4
+ /**
5
+ * Moves the unconfirmed agent and rule links into `proposed_actions` as `relationship` and `key_document`
6
+ * proposals, with their bot, reason and time, so `links` holds what the owner confirmed. The moved row keeps the
7
+ * link's provenance under `suggestedLink`, which marks its proposer unverified: only the owner decides it. A
8
+ * suggestion an older build writes later stays a link, and the proposal kinds still list it.
9
+ */
10
+ export declare const moveSuggestedLinks: (database: CacheDatabase, now: () => number) => number;