@wirecat/cli-core 0.548.0 → 0.550.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 (72) hide show
  1. package/CHANGELOG.md +232 -0
  2. package/dist/calendar/cli/index.d.ts +10 -1
  3. package/dist/calendar/cli/index.js +9 -3
  4. package/dist/calendar/cli/search-all.d.ts +17 -0
  5. package/dist/calendar/cli/search-all.js +72 -0
  6. package/dist/cli/answer-entity.d.ts +4 -0
  7. package/dist/cli/answer-entity.js +5 -0
  8. package/dist/cli/bot/context.d.ts +2 -0
  9. package/dist/cli/compact.d.ts +41 -0
  10. package/dist/cli/compact.js +313 -0
  11. package/dist/cli/context.d.ts +11 -2
  12. package/dist/cli/context.js +15 -6
  13. package/dist/cli/envelope.d.ts +5 -1
  14. package/dist/cli/envelope.js +15 -4
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +2 -0
  17. package/dist/cli/messenger/search-command.d.ts +10 -0
  18. package/dist/cli/messenger/search-command.js +19 -13
  19. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  20. package/dist/cli/program.d.ts +3 -0
  21. package/dist/cli/program.js +7 -1
  22. package/dist/domain/providers.d.ts +3 -0
  23. package/dist/domain/providers.js +20 -4
  24. package/dist/domain/ref-arguments.js +3 -7
  25. package/dist/domain/ref-summaries.d.ts +56 -0
  26. package/dist/domain/ref-summaries.js +63 -0
  27. package/dist/entities/index.d.ts +11 -0
  28. package/dist/entities/relationship.d.ts +2 -0
  29. package/dist/entities/relationship.js +2 -0
  30. package/dist/entities/task-results.d.ts +10 -0
  31. package/dist/entities/task-results.js +1 -0
  32. package/dist/errors/reasons.js +2 -0
  33. package/dist/mail/partial.d.ts +9 -3
  34. package/dist/mail/partial.js +35 -10
  35. package/dist/mail/source.d.ts +7 -1
  36. package/dist/mail/sync.js +1 -1
  37. package/dist/output-detail.js +9 -5
  38. package/dist/output.d.ts +5 -1
  39. package/dist/output.js +29 -4
  40. package/dist/services/proposals/kinds/relationship-update.d.ts +4 -0
  41. package/dist/services/proposals/kinds/relationship-update.js +53 -0
  42. package/dist/services/proposals/registry.js +2 -0
  43. package/dist/store/decisions.d.ts +3 -1
  44. package/dist/store/decisions.js +7 -1
  45. package/dist/store/index.d.ts +2 -1
  46. package/dist/store/sqlite/email-locations.d.ts +22 -1
  47. package/dist/store/sqlite/email-locations.js +29 -2
  48. package/dist/store/sqlite/emails.d.ts +3 -7
  49. package/dist/store/sqlite/emails.js +4 -2
  50. package/dist/store/sqlite/evidence-sources.d.ts +2 -2
  51. package/dist/store/sqlite/evidence-sources.js +41 -77
  52. package/dist/store/sqlite/knowledge.d.ts +17 -23
  53. package/dist/store/sqlite/knowledge.js +13 -13
  54. package/dist/store/sqlite/manifest.js +2 -0
  55. package/dist/store/sqlite/migrations.generated.js +12 -0
  56. package/dist/store/sqlite/notes.d.ts +5 -0
  57. package/dist/store/sqlite/notes.js +6 -2
  58. package/dist/store/sqlite/open-store.js +2 -0
  59. package/dist/store/sqlite/proposed-actions.d.ts +1 -1
  60. package/dist/store/sqlite/proposed-actions.js +10 -2
  61. package/dist/store/sqlite/ref-summaries.d.ts +17 -0
  62. package/dist/store/sqlite/ref-summaries.js +378 -0
  63. package/dist/store/sqlite/relation-proposals.d.ts +40 -0
  64. package/dist/store/sqlite/relation-proposals.js +88 -1
  65. package/dist/store/sqlite/schema.d.ts +16 -0
  66. package/dist/store/sqlite/schema.js +1 -0
  67. package/dist/store/sqlite/suggested-links.d.ts +10 -0
  68. package/dist/store/sqlite/suggested-links.js +68 -0
  69. package/dist/store/store.d.ts +3 -0
  70. package/dist/store/store.js +5 -0
  71. package/package.json +3 -3
  72. package/parity.json +4 -0
@@ -15,7 +15,7 @@ import { meetingProjectsStoreOver } from "./meeting-projects.js";
15
15
  import { holdingText } from "./note-index.js";
16
16
  import { insertLink, linkOf } from "./notes.js";
17
17
  import { queryFilter } from "./query-filter.js";
18
- import { relationProposalsOver } from "./relation-proposals.js";
18
+ import { relationOf, relationProposalsOver, } from "./relation-proposals.js";
19
19
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
20
20
  import { INBOX_KEY_LENGTH, TASK_KEY_PREFIXES } from "./task-keys.js";
21
21
  import { inboxKey, taskAccountOf, taskIdOf, taskRowOf } from "./tasks.js";
@@ -221,18 +221,6 @@ export const knowledgeStoreOver = (context) => {
221
221
  scope: row.scope,
222
222
  createdAt: iso(row.created_at),
223
223
  });
224
- const relationOf = (link) => ({
225
- id: link.id,
226
- from: link.from,
227
- to: String(link.to),
228
- kind: link.kind,
229
- role: link.role,
230
- evidence: link.evidence,
231
- confirmed: link.confirmed,
232
- provenance: link.provenance,
233
- confidenceCategory: link.confirmed ? "owner-confirmed" : "weak",
234
- createdAt: link.createdAt,
235
- });
236
224
  const proposals = relationProposalsOver(context);
237
225
  const RELATIONS = `SELECT * FROM links WHERE kind IN (${RELATION_KINDS.map(linkKind).join(",")}) AND to_id IS NOT NULL`;
238
226
  const relation = (id) => {
@@ -561,6 +549,7 @@ export const knowledgeStoreOver = (context) => {
561
549
  origin: confirmed ? "owner" : "suggested",
562
550
  confirmed,
563
551
  ...(input.role === undefined ? {} : { role: input.role }),
552
+ ...(input.purpose === undefined ? {} : { purpose: input.purpose }),
564
553
  ...(input.evidence === undefined ? {} : { evidence: input.evidence }),
565
554
  ...(input.provenance === undefined ? {} : { provenance: input.provenance }),
566
555
  })));
@@ -573,6 +562,17 @@ export const knowledgeStoreOver = (context) => {
573
562
  });
574
563
  return proposals.propose(from, input, by);
575
564
  },
565
+ updateRelation: (async (key, id, input) => {
566
+ checked(key);
567
+ const link = linkOf(database, relation(id));
568
+ if (input.by === "owner")
569
+ return relationOf(proposals.update(link, input));
570
+ return proposals.proposeUpdate(link, input, input.by);
571
+ }),
572
+ decideRelationUpdate: async (id, decision) => {
573
+ const { proposal, link } = proposals.decideUpdate(id, decision);
574
+ return { proposal, relation: link && relationOf(link) };
575
+ },
576
576
  decideLinkProposal: async (id, decision) => proposals.decideProposal(id, decision),
577
577
  decideSuggestedLink: async (id, decision) => proposals.decideLink(id, decision),
578
578
  relations: async (key, reference) => {
@@ -57,6 +57,8 @@ export const MANIFEST = [
57
57
  { name: "20261031230945_version-41-task-fields", version: 41, minCompatible: 34 },
58
58
  { name: "20261101002523_version-42-task-revision", version: 42, minCompatible: 34 },
59
59
  { name: "20261101011859_version-43-memory-project", version: 43, minCompatible: 34 },
60
+ { name: "20261102050533_version-44-relationship-purpose", version: 44, minCompatible: 34 },
61
+ { name: "20261102050534_version-44-suggested-links-move", version: 44, minCompatible: 34 },
60
62
  ];
61
63
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
62
64
  const migrations = [];
@@ -877,5 +877,17 @@ export const GENERATED = [
877
877
  "ALTER TABLE `memories` ADD `project_id` integer REFERENCES projects(id);",
878
878
  "CREATE INDEX `memories_by_project_id` ON `memories` (`project_id`);"
879
879
  ]
880
+ },
881
+ {
882
+ "name": "20261102050533_version-44-relationship-purpose",
883
+ "statements": [
884
+ "ALTER TABLE `links` ADD `purpose` text;"
885
+ ]
886
+ },
887
+ {
888
+ "name": "20261102050534_version-44-suggested-links-move",
889
+ "statements": [
890
+ "-- A suggestion's references (chat:, msg:) need the store's own encoding, which SQL cannot write, so the next writable\n-- open moves the unconfirmed agent and rule links into `proposed_actions` and deletes this key (moveSuggestedLinks).\nINSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('suggested_links_to_move', '1', unixepoch() * 1000);"
891
+ ]
880
892
  }
881
893
  ];
@@ -56,6 +56,8 @@ export interface Link {
56
56
  origin: (typeof LINK_SOURCES)[number];
57
57
  targetText: string | null;
58
58
  role: string | null;
59
+ /** What a chat is for in a project, from the purposes memo's config lists; `null` means `work`. */
60
+ purpose: string | null;
59
61
  evidence: string | null;
60
62
  provenance: string | null;
61
63
  confirmed: boolean;
@@ -79,6 +81,7 @@ export interface LinkInput {
79
81
  anchor?: string;
80
82
  origin?: (typeof LINK_ORIGINS)[number];
81
83
  role?: string;
84
+ purpose?: string;
82
85
  evidence?: string;
83
86
  provenance?: string;
84
87
  confirmed?: boolean;
@@ -194,6 +197,8 @@ export declare const requiredThing: (database: CacheDatabase, reference: string,
194
197
  * and only a name some note is waiting for costs more.
195
198
  */
196
199
  export declare const resolvePersonLinks: (database: CacheDatabase, names: (string | null | undefined)[]) => void;
200
+ /** The store keeps no list of purposes: memo's config holds it, and checks a purpose before it gets here. */
201
+ export declare const purposeOf: (purpose: string) => string;
197
202
  /** Writes one link; an identical resolved link is answered instead of doubled. */
198
203
  export declare const insertLink: (context: StoreContext, input: LinkInput) => Link;
199
204
  export declare const notesStoreOver: (context: StoreContext) => Omit<NotesStore, keyof NoteSearch>;
@@ -87,6 +87,7 @@ export const linkOf = (database, row) => {
87
87
  origin: row.source,
88
88
  targetText: row.target_text == null ? null : String(row.target_text),
89
89
  role: row.role == null ? null : String(row.role),
90
+ purpose: row.purpose == null ? null : String(row.purpose),
90
91
  evidence: row.evidence == null ? null : String(row.evidence),
91
92
  provenance: metadata.provenance ?? null,
92
93
  confirmed: Number(row.confirmed) === 1,
@@ -164,6 +165,8 @@ const ownerLink = (input) => {
164
165
  });
165
166
  return input;
166
167
  };
168
+ /** The store keeps no list of purposes: memo's config holds it, and checks a purpose before it gets here. */
169
+ export const purposeOf = (purpose) => textOf(purpose, "a purpose", 100);
167
170
  /** Writes one link; an identical resolved link is answered instead of doubled. */
168
171
  export const insertLink = (context, input) => {
169
172
  const { database, now } = context;
@@ -187,8 +190,9 @@ export const insertLink = (context, input) => {
187
190
  const provenance = input.provenance === undefined ? undefined : textOf(input.provenance, "provenance", 2000);
188
191
  const row = database
189
192
  .prepare("INSERT INTO links (from_type, from_id, to_type, to_id, kind, anchor, source, target_text, target_folded, role, evidence, " +
190
- "metadata, confirmed, created_at, author, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
191
- .get(from.type, from.id, to?.type ?? null, to?.id ?? null, kind, input.anchor ?? null, origin, targetText, targetText === null ? null : targetKey(targetText), input.role === undefined ? null : textOf(input.role, "a role", 200), input.evidence === undefined ? null : textOf(input.evidence, "evidence", 2000), provenance === undefined ? null : JSON.stringify({ provenance }), input.confirmed === false ? 0 : 1, at, origin === "owner" ? "owner" : null, at);
193
+ "metadata, confirmed, created_at, author, updated_at, purpose) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
194
+ "RETURNING id")
195
+ .get(from.type, from.id, to?.type ?? null, to?.id ?? null, kind, input.anchor ?? null, origin, targetText, targetText === null ? null : targetKey(targetText), input.role === undefined ? null : textOf(input.role, "a role", 200), input.evidence === undefined ? null : textOf(input.evidence, "evidence", 2000), provenance === undefined ? null : JSON.stringify({ provenance }), input.confirmed === false ? 0 : 1, at, origin === "owner" ? "owner" : null, at, input.purpose === undefined ? null : purposeOf(input.purpose));
192
196
  if (to === null && targetText !== null)
193
197
  resolveKey(database, targetKey(targetText));
194
198
  return linkOf(database, database.prepare("SELECT * FROM links WHERE id = ?").get(Number(row?.id)));
@@ -16,6 +16,7 @@ import { loadProviders } from "./providers.js";
16
16
  import { assertReadCompatible } from "./read-compatibility.js";
17
17
  import { stemsState } from "./stems.js";
18
18
  import { fillStems, followDefaultStemmers } from "./stems-fill.js";
19
+ import { moveSuggestedLinks } from "./suggested-links.js";
19
20
  import { fillVectorIndexes } from "./vector-index.js";
20
21
  export const BACKFILL_ON_OPEN = 5_000;
21
22
  const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
@@ -55,6 +56,7 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
55
56
  fillFiles(database, now);
56
57
  fillFolded(database);
57
58
  fillJoinKeys(database);
59
+ moveSuggestedLinks(database, now);
58
60
  const pending = pendingNormalization(database);
59
61
  if (pending > 0 && pending <= BACKFILL_ON_OPEN)
60
62
  backfillNormalized(database);
@@ -81,7 +81,7 @@ type ProposalOperations = Omit<{
81
81
  proposer(id: string): Proposer;
82
82
  };
83
83
  /** Who proposed an action, for `mayDecide`. */
84
- export declare const proposerOfAction: ({ proposedBy }: ProposedAction) => Proposer;
84
+ export declare const proposerOfAction: ({ proposedBy, payload }: ProposedAction) => Proposer;
85
85
  export declare const proposalOperationsOver: (context: StoreContext) => ProposalOperations;
86
86
  export declare const proposedActionsStoreOver: (context: StoreContext) => ProposedActionsStore;
87
87
  export {};
@@ -20,11 +20,19 @@ const STATUS = `CASE WHEN p.status = 'approved' AND p.kind IN ('${MEETING_FINDIN
20
20
  const NAMED = `SELECT p.*, ${STATUS} AS shown_status, b.name AS proposer_name, b.kind AS proposer_kind FROM proposed_actions p ` +
21
21
  "LEFT JOIN bots b ON p.proposed_by_type = 'bot' AND b.id = p.proposed_by_id";
22
22
  /** Who proposed an action, for `mayDecide`. */
23
- export const proposerOfAction = ({ proposedBy }) => proposedBy.type === "person"
23
+ export const proposerOfAction = ({ proposedBy, payload }) => proposedBy.type === "person"
24
24
  ? { type: "person", id: proposedBy.id }
25
25
  : proposedBy.name === null
26
26
  ? { type: "unknown" }
27
- : { type: "bot", id: proposedBy.id, name: proposedBy.name, kind: proposedBy.kind ?? "agent" };
27
+ : {
28
+ type: "bot",
29
+ id: proposedBy.id,
30
+ name: proposedBy.name,
31
+ kind: proposedBy.kind ?? "agent",
32
+ ...(movedSuggestion(payload) ? { verified: false } : {}),
33
+ };
34
+ /** A suggestion moved from `links` names its bot only as the link's provenance text said it (store version 44). */
35
+ const movedSuggestion = (payload) => typeof payload === "object" && payload !== null && "suggestedLink" in payload;
28
36
  export const proposalOperationsOver = (context) => {
29
37
  const { database, now } = context;
30
38
  const actionOf = (row) => {
@@ -0,0 +1,17 @@
1
+ import { type RefSummary } from "../../domain/ref-summaries.js";
2
+ import type { CacheDatabase } from "../driver.js";
3
+ /** A summary plus what an evidence reader shows beside it; `summaries` drops the extra fields. */
4
+ export interface RefRead extends RefSummary {
5
+ sender: string | null;
6
+ excerpt: string | null;
7
+ }
8
+ /**
9
+ * Every ref read back in one statement per kind present, keyed by the text passed and in its order. A bad ref is
10
+ * `unknown` and a ref the store lacks is `unavailable`: one bad row must not fail a page of them.
11
+ */
12
+ export declare const refReadsOf: (database: CacheDatabase, texts: readonly string[], { excerpt }?: {
13
+ excerpt?: boolean;
14
+ }) => Map<string, RefRead>;
15
+ export declare const refSummariesOf: (database: CacheDatabase, texts: readonly string[], options?: {
16
+ excerpt?: boolean;
17
+ }) => Map<string, RefSummary>;
@@ -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;