@wirecat/cli-core 0.546.0 → 0.547.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 (96) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/cli/messenger/history-query.d.ts +10 -4
  3. package/dist/cli/messenger/history-query.js +87 -31
  4. package/dist/cli/messenger/messages-command.js +10 -4
  5. package/dist/cli/messenger/messages-comments-command.js +4 -2
  6. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  7. package/dist/cli/option-hints.d.ts +6 -0
  8. package/dist/cli/option-hints.js +15 -0
  9. package/dist/errors/reasons.js +13 -1
  10. package/dist/mail/cli/index.js +4 -4
  11. package/dist/mail/index.d.ts +2 -0
  12. package/dist/mail/index.js +2 -0
  13. package/dist/mail/partial.d.ts +34 -0
  14. package/dist/mail/partial.js +94 -0
  15. package/dist/mail/server-search.js +17 -9
  16. package/dist/mail/source.d.ts +13 -2
  17. package/dist/mail/source.js +7 -1
  18. package/dist/mail/sync-types.d.ts +112 -0
  19. package/dist/mail/sync-types.js +1 -0
  20. package/dist/mail/sync.d.ts +4 -106
  21. package/dist/mail/sync.js +49 -4
  22. package/dist/mail/testing/fake.d.ts +3 -1
  23. package/dist/mail/testing/fake.js +10 -2
  24. package/dist/services/deps.d.ts +0 -2
  25. package/dist/services/deps.js +0 -2
  26. package/dist/services/documents/ingestion.js +1 -1
  27. package/dist/services/documents/knowledge.d.ts +10 -6
  28. package/dist/services/documents/knowledge.js +25 -11
  29. package/dist/services/index.d.ts +1 -0
  30. package/dist/services/index.js +1 -0
  31. package/dist/services/messages.d.ts +2 -0
  32. package/dist/services/messages.js +25 -23
  33. package/dist/services/proposal-tasks.d.ts +3 -2
  34. package/dist/services/proposal-tasks.js +5 -2
  35. package/dist/services/proposals/ids.d.ts +5 -0
  36. package/dist/services/proposals/ids.js +43 -0
  37. package/dist/services/proposals/index.d.ts +3 -0
  38. package/dist/services/proposals/index.js +2 -0
  39. package/dist/services/proposals/kinds/action.d.ts +7 -0
  40. package/dist/services/proposals/kinds/action.js +45 -0
  41. package/dist/services/proposals/kinds/fact.d.ts +4 -0
  42. package/dist/services/proposals/kinds/fact.js +43 -0
  43. package/dist/services/proposals/kinds/findings.d.ts +7 -0
  44. package/dist/services/proposals/kinds/findings.js +75 -0
  45. package/dist/services/proposals/kinds/key-document.d.ts +9 -0
  46. package/dist/services/proposals/kinds/key-document.js +75 -0
  47. package/dist/services/proposals/kinds/memory.d.ts +4 -0
  48. package/dist/services/proposals/kinds/memory.js +42 -0
  49. package/dist/services/proposals/kinds/relationship.d.ts +10 -0
  50. package/dist/services/proposals/kinds/relationship.js +82 -0
  51. package/dist/services/proposals/kinds/shared.d.ts +25 -0
  52. package/dist/services/proposals/kinds/shared.js +49 -0
  53. package/dist/services/proposals/registry.d.ts +9 -0
  54. package/dist/services/proposals/registry.js +129 -0
  55. package/dist/services/proposals/types.d.ts +85 -0
  56. package/dist/services/proposals/types.js +1 -0
  57. package/dist/services/server-search.d.ts +7 -4
  58. package/dist/services/server-search.js +5 -2
  59. package/dist/services/tasks.js +8 -4
  60. package/dist/store/decisions.d.ts +65 -0
  61. package/dist/store/decisions.js +55 -0
  62. package/dist/store/evidence-findings.d.ts +6 -6
  63. package/dist/store/index.d.ts +4 -3
  64. package/dist/store/index.js +2 -1
  65. package/dist/store/meeting-findings.d.ts +2 -0
  66. package/dist/store/messages.d.ts +4 -9
  67. package/dist/store/sqlite/actors.d.ts +13 -2
  68. package/dist/store/sqlite/actors.js +24 -1
  69. package/dist/store/sqlite/email-locations.d.ts +16 -0
  70. package/dist/store/sqlite/email-locations.js +26 -0
  71. package/dist/store/sqlite/emails.d.ts +6 -2
  72. package/dist/store/sqlite/emails.js +2 -1
  73. package/dist/store/sqlite/evidence-findings.js +13 -3
  74. package/dist/store/sqlite/facts.d.ts +16 -8
  75. package/dist/store/sqlite/facts.js +48 -15
  76. package/dist/store/sqlite/findings-core.d.ts +2 -1
  77. package/dist/store/sqlite/findings-core.js +4 -4
  78. package/dist/store/sqlite/knowledge.d.ts +43 -7
  79. package/dist/store/sqlite/knowledge.js +55 -15
  80. package/dist/store/sqlite/meeting-findings.js +6 -6
  81. package/dist/store/sqlite/memories.d.ts +12 -8
  82. package/dist/store/sqlite/memories.js +33 -10
  83. package/dist/store/sqlite/notes.d.ts +5 -1
  84. package/dist/store/sqlite/notes.js +18 -2
  85. package/dist/store/sqlite/open-store.d.ts +4 -1
  86. package/dist/store/sqlite/open-store.js +8 -2
  87. package/dist/store/sqlite/open.d.ts +3 -0
  88. package/dist/store/sqlite/proposed-actions.d.ts +21 -8
  89. package/dist/store/sqlite/proposed-actions.js +29 -9
  90. package/dist/store/sqlite/reads.d.ts +13 -2
  91. package/dist/store/sqlite/reads.js +8 -4
  92. package/dist/store/sqlite/relation-proposals.d.ts +34 -0
  93. package/dist/store/sqlite/relation-proposals.js +114 -0
  94. package/dist/store/store.d.ts +3 -0
  95. package/dist/store/store.js +2 -0
  96. package/package.json +1 -1
@@ -1,12 +1,14 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
+ import { assertMayDecide, assertMayWithdraw, deciderAuthor } from "../decisions.js";
5
+ import { proposedBySql, proposerOf } from "./actors.js";
4
6
  import { evidenceSourcesOf } from "./evidence-sources.js";
5
7
  import { linkKind } from "./link-kinds.js";
6
8
  import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, keepHighestId, linkEvidence, nextId, projectOf, subjectOf, } from "./memories.js";
7
9
  import { requiredThing } from "./notes.js";
8
10
  import { write } from "./transaction.js";
9
- const NAMED = "SELECT f.*, b.name AS author_name FROM facts f LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id";
11
+ const NAMED = "SELECT f.*, b.name AS author_name, b.kind AS author_kind FROM facts f LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id";
10
12
  const iso = (value) => new Date(Number(value)).toISOString();
11
13
  const oneOf = (value, allowed, what) => {
12
14
  if (typeof value !== "string" || !allowed.includes(value))
@@ -42,15 +44,32 @@ export const factOperationsOver = (context) => {
42
44
  throw new CliError("validation_error", `${reference} is not a fact`);
43
45
  return database.prepare(`${NAMED} WHERE f.id = ?`).get(thing.id);
44
46
  };
45
- const accept = (id) => {
47
+ const withdrawn = (row, by) => assertMayWithdraw({
48
+ id: `fact:${row.id}`,
49
+ proposedBy: proposerOf(database, row.author_type, row.author_id),
50
+ proposed: row.status === "proposed",
51
+ }, by);
52
+ const decided = (row, by) => assertMayDecide({ id: `fact:${row.id}`, proposedBy: proposerOf(database, row.author_type, row.author_id) }, by, context.decisions);
53
+ /** Keeps who decided for the owner and why in `metadata.decision`; the decider's actor is in `confirmed_by`. */
54
+ const keepDecision = (id, by, reason) => {
55
+ const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
56
+ if (by === "owner" && !why)
57
+ return;
58
+ const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
59
+ database
60
+ .prepare("UPDATE facts SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?")
61
+ .run(JSON.stringify(decision), id);
62
+ };
63
+ const accept = (id, by = "owner", reason) => {
46
64
  const row = database.prepare("SELECT * FROM facts WHERE id = ?").get(id);
47
65
  if (row.status !== "proposed" && row.status !== "accepted")
48
66
  throw new CliError("validation_error", `a ${String(row.status)} fact cannot be accepted`);
49
- const { actor } = authorActor(context, "owner");
67
+ const { actor } = authorActor(context, deciderAuthor(by));
50
68
  const at = now();
51
69
  database
52
70
  .prepare("UPDATE facts SET status = 'accepted', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? WHERE id = ?")
53
71
  .run(actor.type, actor.id, at, id);
72
+ keepDecision(id, by, reason);
54
73
  if (row.supersedes_id != null)
55
74
  database
56
75
  .prepare("UPDATE facts SET status = 'superseded', updated_at = ? WHERE id = ? AND status IN ('proposed', 'accepted')")
@@ -100,20 +119,29 @@ export const factOperationsOver = (context) => {
100
119
  list: (options = {}) => {
101
120
  const project = options.project === undefined ? undefined : requiredThing(database, options.project, "the project");
102
121
  const subject = options.subject === undefined ? undefined : requiredThing(database, options.subject);
122
+ const by = proposedBySql("f.author_type", "f.author_id", options.proposedBy);
123
+ const order = options.order === "created" ? "f.created_at" : "coalesce(f.occurred_at, f.created_at)";
103
124
  return database
104
125
  .prepare(`${NAMED} WHERE f.deleted_at IS NULL AND (? IS NULL OR f.type = ?) AND (? IS NULL OR f.project_id = ?) ` +
105
126
  "AND (? IS NULL OR (f.subject_type = ? AND f.subject_id = ?)) AND (? IS NULL OR f.status = ?) " +
106
- "ORDER BY coalesce(f.occurred_at, f.created_at) DESC, f.id DESC LIMIT ?")
107
- .all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, Math.min(Math.max(options.limit ?? 100, 1), 500))
127
+ `AND ${by.sql} ORDER BY ${order} DESC, f.id DESC LIMIT ?`)
128
+ .all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, ...by.params, Math.min(Math.max(options.limit ?? 100, 1), 500))
108
129
  .map(factOf);
109
130
  },
110
- accept: (reference) => {
131
+ accept: (reference, by, reason) => {
132
+ const row = rowOf(reference);
133
+ decided(row, by);
134
+ write(database, () => accept(Number(row.id), by, reason));
135
+ return factOf(rowOf(reference));
136
+ },
137
+ acceptChecked: (reference, by) => {
111
138
  const id = Number(rowOf(reference).id);
112
- write(database, () => accept(id));
139
+ write(database, () => accept(id, by));
113
140
  return factOf(rowOf(reference));
114
141
  },
115
- reverse: (reference) => {
142
+ reverse: (reference, by) => {
116
143
  const row = rowOf(reference);
144
+ assertMayWithdraw({ id: `fact:${row.id}`, proposedBy: { type: "unknown" }, proposed: false }, by);
117
145
  if (row.type !== "decision")
118
146
  throw new CliError("validation_error", "only a decision can be reversed");
119
147
  if (row.status !== "accepted")
@@ -121,15 +149,20 @@ export const factOperationsOver = (context) => {
121
149
  database.prepare("UPDATE facts SET status = 'reversed', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
122
150
  return factOf(rowOf(reference));
123
151
  },
124
- reject: (reference) => {
152
+ reject: (reference, by, reason) => {
125
153
  const row = rowOf(reference);
154
+ decided(row, by);
126
155
  if (row.status !== "proposed")
127
156
  throw new CliError("validation_error", `only a proposed fact can be rejected; this one is ${String(row.status)}`);
128
- database.prepare("UPDATE facts SET status = 'rejected', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
157
+ write(database, () => {
158
+ database.prepare("UPDATE facts SET status = 'rejected', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
159
+ keepDecision(Number(row.id), by, reason);
160
+ });
129
161
  return factOf(rowOf(reference));
130
162
  },
131
- remove: (reference) => {
163
+ remove: (reference, by) => {
132
164
  const row = rowOf(reference);
165
+ withdrawn(row, by);
133
166
  const id = Number(row.id);
134
167
  const twin = row.source === "memory" && row.metadata != null ? migratedFrom(String(row.metadata)) : undefined;
135
168
  const links = erase(database, () => {
@@ -165,10 +198,10 @@ export const factsStoreOver = (context) => {
165
198
  add: async (input) => operations.add(input),
166
199
  get: async (reference) => operations.get(reference),
167
200
  list: async (options) => operations.list(options),
168
- accept: async (reference) => operations.accept(reference),
169
- reverse: async (reference) => operations.reverse(reference),
170
- reject: async (reference) => operations.reject(reference),
171
- remove: async (reference) => operations.remove(reference),
201
+ accept: async (reference, by, reason) => operations.accept(reference, by, reason),
202
+ reverse: async (reference, by) => operations.reverse(reference, by),
203
+ reject: async (reference, by, reason) => operations.reject(reference, by, reason),
204
+ remove: async (reference, by) => operations.remove(reference, by),
172
205
  sources: async (reference) => operations.sources(reference),
173
206
  };
174
207
  };
@@ -1,4 +1,5 @@
1
1
  import { type Finding, type FindingEvidence, type FindingOutcome, type FindingsInput } from "../../meetings/index.js";
2
+ import type { Decider } from "../decisions.js";
2
3
  import type { StoreContext } from "./open.js";
3
4
  export interface FindingsAgent {
4
5
  bot: string;
@@ -14,5 +15,5 @@ export declare const findingsCoreOver: (context: StoreContext, kind: string) =>
14
15
  snapshot: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string | null, seen: Map<string, unknown>) => void) => string;
15
16
  snapshotParts: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string | null, seen: Map<string, unknown>) => void) => Record<string, string>;
16
17
  previous: (account: number | null, fingerprint: string) => FindingOutcome | undefined;
17
- outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent) => FindingOutcome[];
18
+ outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent, decidedBy: Decider) => FindingOutcome[];
18
19
  };
@@ -68,7 +68,7 @@ export const findingsCoreOver = (context, kind) => {
68
68
  .get(account, kind, fingerprint);
69
69
  return row ? JSON.parse(String(row.item)) : undefined;
70
70
  };
71
- const create = (finding, agent) => {
71
+ const create = (finding, agent, decidedBy) => {
72
72
  const evidence = finding.evidence.map((item) => item.reference);
73
73
  if (finding.kind === "fact") {
74
74
  const fact = facts.add({
@@ -79,7 +79,7 @@ export const findingsCoreOver = (context, kind) => {
79
79
  evidence,
80
80
  by: agent,
81
81
  });
82
- return facts.accept(fact.ref).ref;
82
+ return facts.acceptChecked(fact.ref, decidedBy).ref;
83
83
  }
84
84
  if (finding.project === undefined)
85
85
  return invalid("a task finding names the project the task goes in");
@@ -99,7 +99,7 @@ export const findingsCoreOver = (context, kind) => {
99
99
  return task.ref;
100
100
  };
101
101
  /** Reuses an earlier decision on an unchanged candidate; refuses to accept one that was rejected. */
102
- const outcomes = (account, input, accepted, agent) => input.findings.map((finding) => {
102
+ const outcomes = (account, input, accepted, agent, decidedBy) => input.findings.map((finding) => {
103
103
  const fingerprint = findingFingerprint(finding);
104
104
  const prior = previous(account, fingerprint);
105
105
  if (prior) {
@@ -112,7 +112,7 @@ export const findingsCoreOver = (context, kind) => {
112
112
  id: finding.id,
113
113
  fingerprint,
114
114
  status,
115
- reference: status === "accepted" ? create(finding, agent) : null,
115
+ reference: status === "accepted" ? create(finding, agent, decidedBy) : null,
116
116
  reused: false,
117
117
  };
118
118
  });
@@ -1,8 +1,13 @@
1
1
  import { type QueryAst } from "@wirecat/cli-base/query";
2
+ import { type Decider } from "../decisions.js";
2
3
  import type { AccountKey } from "../types.js";
3
4
  import { RELATION_KINDS } from "./link-kinds.js";
4
5
  import { type MeetingProjectsStore } from "./meeting-projects.js";
6
+ import type { Author } from "./memories.js";
7
+ import { type Link } from "./notes.js";
5
8
  import type { StoreContext } from "./open.js";
9
+ import type { ProposedAction } from "./proposed-actions.js";
10
+ import { type LinkDecision } from "./relation-proposals.js";
6
11
  import type { TagCreationOptions } from "./tag-creation.js";
7
12
  import { type Label, type TagKind, type TagRow } from "./tags.js";
8
13
  import { type ThingState } from "./things.js";
@@ -177,21 +182,43 @@ export interface KnowledgeStore extends VocabularyStore, MeetingProjectsStore {
177
182
  description?: string;
178
183
  }): Promise<Project>;
179
184
  projects(query?: QueryAst): Promise<Project[]>;
180
- relate(key: KnowledgeScope, input: {
185
+ /** The owner's relationship is a link; `confirmed: false` keeps it a weak suggestion only the owner decides. */
186
+ relate(key: KnowledgeScope, input: RelateInput & {
187
+ by: "owner";
188
+ }): Promise<KnowledgeRelation>;
189
+ /** An agent's relationship is a `relationship` proposal for the owner, never a link. */
190
+ relate(key: KnowledgeScope, input: RelateInput & {
191
+ by: Exclude<Author, "owner">;
192
+ }): Promise<ProposedAction>;
193
+ /**
194
+ * An agent's or a rule's relationship, written as a `relationship` proposal for the owner instead of a link. The
195
+ * same pending pair returns the proposal already held.
196
+ */
197
+ proposeRelation(key: KnowledgeScope, input: {
181
198
  from: string;
182
199
  to: string;
183
200
  kind: KnowledgeRelation["kind"];
184
201
  role?: string;
185
- evidence?: string;
186
- confirmed?: boolean;
187
- provenance?: string;
188
- }): Promise<KnowledgeRelation>;
202
+ evidence?: string[];
203
+ reason?: string;
204
+ }, by: Exclude<Author, "owner">): Promise<ProposedAction>;
205
+ /** Decides a stored `relationship` or `key_document` proposal; the link and the decision land together. */
206
+ decideLinkProposal(id: string, decision: LinkDecision): Promise<{
207
+ proposal: ProposedAction;
208
+ link: Link | null;
209
+ }>;
210
+ /** Decides an unconfirmed link written before proposals were stored. Its author is unknown, so only the owner may. */
211
+ decideSuggestedLink(id: string, decision: LinkDecision): Promise<Link | null>;
189
212
  relations(key: KnowledgeScope, reference?: string): Promise<KnowledgeRelation[]>;
190
- removeRelation(key: KnowledgeScope, id: string): Promise<{
213
+ /**
214
+ * Removing an unconfirmed relationship rejects it, so `by` must be allowed to decide it. Removing a link to a
215
+ * project, or rejecting any suggestion, leaves a rejected `relationship` proposal so a scan never proposes it again.
216
+ */
217
+ removeRelation(key: KnowledgeScope, id: string, by: Decider): Promise<{
191
218
  id: string;
192
219
  removed: true;
193
220
  }>;
194
- confirmRelation(key: KnowledgeScope, id: string): Promise<KnowledgeRelation>;
221
+ confirmRelation(key: KnowledgeScope, id: string, by: Decider): Promise<KnowledgeRelation>;
195
222
  schedule(key: AccountKey, task: string, dueAt: string, timezone: string): Promise<Reminder>;
196
223
  reminders(key: AccountKey, query?: QueryAst): Promise<Reminder[]>;
197
224
  cancelReminder(key: AccountKey, id: string): Promise<Reminder>;
@@ -202,4 +229,13 @@ export interface KnowledgeStore extends VocabularyStore, MeetingProjectsStore {
202
229
  }): Promise<Reminder[]>;
203
230
  acknowledgeReminder(key: AccountKey, id: string, receipt: string): Promise<Reminder>;
204
231
  }
232
+ export interface RelateInput {
233
+ from: string;
234
+ to: string;
235
+ kind: KnowledgeRelation["kind"];
236
+ role?: string;
237
+ evidence?: string;
238
+ confirmed?: boolean;
239
+ provenance?: string;
240
+ }
205
241
  export declare const knowledgeStoreOver: (context: StoreContext) => KnowledgeStore;
@@ -6,6 +6,7 @@ import { normalizeTag } from "../../domain/tags.js";
6
6
  import { normalizeVocabularyName } from "../../domain/vocabulary.js";
7
7
  import { formatMeetingReference } from "../../meetings/index.js";
8
8
  import { isClosedState } from "../../tasks/index.js";
9
+ import { assertMayDecide } from "../decisions.js";
9
10
  import { fold } from "../normalize.js";
10
11
  import { ulid } from "../ulid.js";
11
12
  import { ownerPerson } from "./actors.js";
@@ -14,6 +15,7 @@ import { meetingProjectsStoreOver } from "./meeting-projects.js";
14
15
  import { holdingText } from "./note-index.js";
15
16
  import { insertLink, linkOf } from "./notes.js";
16
17
  import { queryFilter } from "./query-filter.js";
18
+ import { relationProposalsOver } from "./relation-proposals.js";
17
19
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
18
20
  import { INBOX_KEY_LENGTH, TASK_KEY_PREFIXES } from "./task-keys.js";
19
21
  import { inboxKey, taskAccountOf, taskIdOf, taskRowOf } from "./tasks.js";
@@ -24,6 +26,11 @@ const TYPED_KEY_PREFIXES = new Set(Object.values(TASK_KEY_PREFIXES));
24
26
  export const ORGANIZATION_KINDS = ["company", "team", "family", "community"];
25
27
  export const PROJECT_TYPES = ["work", "client", "personal", "oss", "other"];
26
28
  export const SCOPES = ["personal", "work"];
29
+ /**
30
+ * A link records no actor, only free-text provenance anyone could write, so whoever proposed one is unverified and
31
+ * only the owner decides it.
32
+ */
33
+ const UNVERIFIED = { type: "unknown" };
27
34
  const iso = (value) => new Date(Number(value)).toISOString();
28
35
  const bounded = (limit = 100, offset = 0) => {
29
36
  if (!Number.isInteger(limit) ||
@@ -226,6 +233,7 @@ export const knowledgeStoreOver = (context) => {
226
233
  confidenceCategory: link.confirmed ? "owner-confirmed" : "weak",
227
234
  createdAt: link.createdAt,
228
235
  });
236
+ const proposals = relationProposalsOver(context);
229
237
  const RELATIONS = `SELECT * FROM links WHERE kind IN (${RELATION_KINDS.map(linkKind).join(",")}) AND to_id IS NOT NULL`;
230
238
  const relation = (id) => {
231
239
  const row = /^\d+$/.test(id) ? database.prepare(`${RELATIONS} AND id = ?`).get(Number(id)) : undefined;
@@ -233,6 +241,19 @@ export const knowledgeStoreOver = (context) => {
233
241
  throw new CliError("not_found", "no relationship with that id");
234
242
  return row;
235
243
  };
244
+ /** The two ends of a relationship, checked, and the link that already joins them. */
245
+ const pair = (key, input) => {
246
+ const from = related(key, input.from, true);
247
+ const to = related(key, input.to);
248
+ if ((from.type === to.type && from.id === to.id) || !RELATION_KINDS.includes(input.kind))
249
+ throw new CliError("validation_error", "a relationship needs distinct references and a supported kind");
250
+ if ((from.type === "chat" || from.type === "email_thread") && (input.kind !== "member-of" || to.type !== "project"))
251
+ throw new CliError("validation_error", "a chat is related only as member-of a project");
252
+ const existing = database
253
+ .prepare(`${RELATIONS} AND from_type=? AND from_id=? AND to_type=? AND to_id=? AND kind=?`)
254
+ .get(from.type, from.id, to.type, to.id, input.kind);
255
+ return { from, to, existing };
256
+ };
236
257
  /** A chat may only be the member: its reference names its own account, so it is never another account's. */
237
258
  const related = (key, reference, member = false) => {
238
259
  checked(key);
@@ -517,17 +538,19 @@ export const knowledgeStoreOver = (context) => {
517
538
  .all(...(where?.params ?? []))
518
539
  .map(projectOf);
519
540
  },
520
- relate: async (key, input) => {
521
- const from = related(key, input.from, true);
522
- const to = related(key, input.to);
523
- if ((from.type === to.type && from.id === to.id) || !RELATION_KINDS.includes(input.kind))
524
- throw new CliError("validation_error", "a relationship needs distinct references and a supported kind");
525
- if ((from.type === "chat" || from.type === "email_thread") &&
526
- (input.kind !== "member-of" || to.type !== "project"))
527
- throw new CliError("validation_error", "a chat is related only as member-of a project");
528
- const existing = database
529
- .prepare(`${RELATIONS} AND from_type=? AND from_id=? AND to_type=? AND to_id=? AND kind=?`)
530
- .get(from.type, from.id, to.type, to.id, input.kind);
541
+ relate: (async (key, input) => {
542
+ if (input.by === undefined)
543
+ throw new CliError("validation_error", "a relationship names who writes it: the owner or an agent", {
544
+ reason: "proposal.decider-required",
545
+ });
546
+ const { from, existing } = pair(key, input);
547
+ if (input.by !== "owner") {
548
+ if (existing)
549
+ throw new CliError("validation_error", `relationship ${String(existing.id)} already links them`, {
550
+ reason: "relationship.exists",
551
+ });
552
+ return proposals.propose(from, { ...input, ...(input.evidence === undefined ? {} : { reason: input.evidence }), evidence: [] }, input.by);
553
+ }
531
554
  if (existing)
532
555
  return relationOf(linkOf(database, existing));
533
556
  const confirmed = input.confirmed !== false;
@@ -541,7 +564,17 @@ export const knowledgeStoreOver = (context) => {
541
564
  ...(input.evidence === undefined ? {} : { evidence: input.evidence }),
542
565
  ...(input.provenance === undefined ? {} : { provenance: input.provenance }),
543
566
  })));
567
+ }),
568
+ proposeRelation: async (key, input, by) => {
569
+ const { from, existing } = pair(key, input);
570
+ if (existing)
571
+ throw new CliError("validation_error", `relationship ${String(existing.id)} already links them`, {
572
+ reason: "relationship.exists",
573
+ });
574
+ return proposals.propose(from, input, by);
544
575
  },
576
+ decideLinkProposal: async (id, decision) => proposals.decideProposal(id, decision),
577
+ decideSuggestedLink: async (id, decision) => proposals.decideLink(id, decision),
545
578
  relations: async (key, reference) => {
546
579
  checked(key);
547
580
  const thing = reference === undefined ? undefined : thingOf(database, reference);
@@ -552,16 +585,23 @@ export const knowledgeStoreOver = (context) => {
552
585
  .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, thing?.type ?? null, thing?.id ?? null)
553
586
  .map((row) => relationOf(linkOf(database, row)));
554
587
  },
555
- removeRelation: async (key, id) => {
588
+ removeRelation: async (key, id, by) => {
556
589
  checked(key);
557
- relation(id);
558
- database.prepare("DELETE FROM links WHERE id = ?").run(Number(id));
590
+ const row = relation(id);
591
+ const link = linkOf(database, row);
592
+ assertMayDecide({ id: `link:${id}`, proposedBy: UNVERIFIED }, by, context.decisions);
593
+ write(database, () => {
594
+ if (!link.confirmed || String(row.to_type) === "project")
595
+ proposals.rejected(link, by);
596
+ database.prepare("DELETE FROM links WHERE id = ?").run(Number(id));
597
+ });
559
598
  return { id, removed: true };
560
599
  },
561
- confirmRelation: async (key, id) => {
600
+ confirmRelation: async (key, id, by) => {
562
601
  checked(key);
563
602
  const row = relation(id);
564
603
  const link = linkOf(database, row);
604
+ assertMayDecide({ id: `link:${id}`, proposedBy: UNVERIFIED }, by, context.decisions);
565
605
  related(key, link.from, true);
566
606
  related(key, String(link.to));
567
607
  database
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { findingHash, MEETING_FINDINGS_KIND, meetingCueFingerprint, meetingFindingFingerprint, parseMeetingReference, validateMeetingFindings, } from "../../meetings/index.js";
3
+ import { assertMayDecide, deciderAuthor } from "../decisions.js";
3
4
  import { findingsCoreOver } from "./findings-core.js";
4
5
  import { authorActor } from "./memories.js";
5
6
  import { proposalOperationsOver } from "./proposed-actions.js";
@@ -176,6 +177,9 @@ export const meetingFindingsStoreOver = (context) => {
176
177
  decision.accept.some((candidate) => !batch.input.findings.some((finding) => finding.id === candidate)))
177
178
  return invalid("accept only unique IDs shown in this batch");
178
179
  const accepted = [...decision.accept].sort();
180
+ if (decision.by === undefined)
181
+ return invalid("a findings review names who decides: the owner or an agent");
182
+ assertMayDecide({ id, proposedBy: proposals.proposer(id) }, decision.by, context.decisions);
179
183
  if (batch.receipt) {
180
184
  if (JSON.stringify(batch.receipt.accepted) !== JSON.stringify(accepted))
181
185
  return invalid("this findings batch was already reviewed with a different decision");
@@ -191,12 +195,8 @@ export const meetingFindingsStoreOver = (context) => {
191
195
  bot: proposal.proposedBy.name,
192
196
  ...(input.agentModel === null ? {} : { model: input.agentModel }),
193
197
  };
194
- const items = core.outcomes(account, batch.input, accepted, agent);
195
- return view(proposals.approve(id, {
196
- previewFingerprint: batch.fingerprint,
197
- accepted,
198
- items,
199
- }));
198
+ const items = core.outcomes(account, batch.input, accepted, agent, decision.by);
199
+ return view(proposals.approve(id, { previewFingerprint: batch.fingerprint, accepted, items }, deciderAuthor(decision.by)));
200
200
  }),
201
201
  };
202
202
  };
@@ -1,12 +1,14 @@
1
1
  import { MEMORY_KINDS, MEMORY_SCOPES, type MEMORY_STATUSES } from "../../domain/knowledge.js";
2
- import { type Actor } from "./actors.js";
2
+ import { type Decider } from "../decisions.js";
3
+ import { type Actor, type ProposedByFilter } from "./actors.js";
3
4
  import { type EvidenceSource } from "./evidence-sources.js";
4
5
  import { type NoteIndexState } from "./note-index.js";
5
6
  import type { StoreContext } from "./open.js";
6
- /** Who wrote it: the owner, or an agent by its bot handle and the model it ran on. */
7
+ /** Who wrote it: the owner, or an agent (or a rule scorer) by its bot handle and the model it ran on. */
7
8
  export type Author = "owner" | {
8
9
  bot: string;
9
10
  model?: string;
11
+ kind?: "agent" | "rule";
10
12
  };
11
13
  export interface Memory {
12
14
  id: string;
@@ -22,11 +24,12 @@ export interface Memory {
22
24
  /** The project it belongs to, as `project:<id>`. */
23
25
  project: string | null;
24
26
  projectName: string | null;
25
- /** `name` is a bot's handle; `null` for a person. */
27
+ /** `name` is a bot's handle and `kind` whether it is an agent or a rule; both `null` for a person. */
26
28
  author: {
27
29
  type: Actor["type"];
28
30
  id: string;
29
31
  name: string | null;
32
+ kind: "agent" | "rule" | null;
30
33
  };
31
34
  model: string | null;
32
35
  confidence: number | null;
@@ -63,21 +66,22 @@ export interface MemoriesStore {
63
66
  kind?: Memory["kind"];
64
67
  status?: Memory["status"];
65
68
  scope?: Memory["scope"];
69
+ proposedBy?: ProposedByFilter;
66
70
  limit?: number;
67
71
  }): Promise<Memory[]>;
68
72
  /** Moves it to another project, or to none with `null`. */
69
73
  update(reference: string, change: {
70
74
  project: string | null;
71
75
  }): Promise<Memory>;
72
- /** The owner stands behind it; its evidence was checked now. */
73
- confirm(reference: string): Promise<Memory>;
74
- /** Its evidence no longer holds. */
75
- markStale(reference: string): Promise<Memory>;
76
+ /** The owner, or an agent for the owner, stands behind it; its evidence was checked now. */
77
+ confirm(reference: string, by: Decider): Promise<Memory>;
78
+ /** Its evidence no longer holds; for an agent's memory this is how it is turned down, so `by` must be allowed. */
79
+ markStale(reference: string, by: Decider): Promise<Memory>;
76
80
  /**
77
81
  * Erases the memory: its row, its text in the search and meaning indexes, and every link from or to it. A memory
78
82
  * that replaced it no longer names it. A backup made before keeps it.
79
83
  */
80
- remove(reference: string): Promise<{
84
+ remove(reference: string, by: Decider): Promise<{
81
85
  ref: string;
82
86
  removed: true;
83
87
  links: number;
@@ -1,7 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { MEMORY_KINDS, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
+ import { assertMayDecide, assertMayWithdraw } from "../decisions.js";
3
4
  import { normalize } from "../normalize.js";
4
- import { botNamed, ownerPerson } from "./actors.js";
5
+ import { botNamed, ownerPerson, proposedBySql, proposerOf } from "./actors.js";
5
6
  import { evidenceSourcesOf } from "./evidence-sources.js";
6
7
  import { linkKind } from "./link-kinds.js";
7
8
  import { CORPORA, corpusIndexState, drainCorpus } from "./note-index.js";
@@ -9,7 +10,7 @@ import { requiredThing } from "./notes.js";
9
10
  import { stemmerCache } from "./stems.js";
10
11
  import { referenceOfThing, storedThing } from "./things.js";
11
12
  import { write } from "./transaction.js";
12
- const NAMED = "SELECT m.*, b.name AS author_name FROM memories m LEFT JOIN bots b ON m.author_type = 'bot' AND b.id = m.author_id";
13
+ const NAMED = "SELECT m.*, b.name AS author_name, b.kind AS author_kind FROM memories m LEFT JOIN bots b ON m.author_type = 'bot' AND b.id = m.author_id";
13
14
  const iso = (value) => new Date(Number(value)).toISOString();
14
15
  const oneOf = (value, allowed, what) => {
15
16
  if (typeof value !== "string" || !allowed.includes(value))
@@ -20,7 +21,7 @@ export const authorActor = (context, author) => {
20
21
  if (author === "owner")
21
22
  return { actor: ownerPerson(context.database), model: null };
22
23
  return {
23
- actor: botNamed(context.database, author.bot, context.now(), "agent"),
24
+ actor: botNamed(context.database, author.bot, context.now(), author.kind ?? "agent"),
24
25
  model: author.model === undefined ? null : author.model.trim().slice(0, 200) || null,
25
26
  };
26
27
  };
@@ -28,6 +29,7 @@ export const authorOf = (row) => ({
28
29
  type: row.author_type,
29
30
  id: String(row.author_id),
30
31
  name: row.author_name == null ? null : String(row.author_name),
32
+ kind: row.author_type !== "bot" ? null : row.author_kind === "rule" || row.author_name === "rule" ? "rule" : "agent",
31
33
  });
32
34
  /** Writes `evidence` links from a row to each reference, refusing one the store does not hold. */
33
35
  export const linkEvidence = (context, from, references, author) => {
@@ -153,6 +155,10 @@ export const memoriesStoreOver = (context) => {
153
155
  throw new CliError("not_found", `${reference} is a fact now: store.facts holds it`);
154
156
  return row;
155
157
  };
158
+ const decided = (reference, by) => {
159
+ const row = rowOf(reference);
160
+ assertMayDecide({ id: `memory:${row.id}`, proposedBy: proposerOf(database, row.author_type, row.author_id) }, by, context.decisions);
161
+ };
156
162
  const setStatus = (reference, status, verified) => {
157
163
  const row = rowOf(reference);
158
164
  const at = now();
@@ -179,6 +185,10 @@ export const memoriesStoreOver = (context) => {
179
185
  if (input.confidence !== undefined && !(input.confidence >= 0 && input.confidence <= 1))
180
186
  throw new CliError("validation_error", "confidence takes 0–1");
181
187
  const status = oneOf(input.status ?? "proposed", ["proposed", "confirmed"], "a new memory's status");
188
+ if (status === "confirmed" && input.author !== "owner")
189
+ throw new CliError("permission_error", "an agent's memory waits as proposed; only the owner confirms it", {
190
+ reason: "proposal.owner-only",
191
+ });
182
192
  const id = write(database, () => {
183
193
  const { actor, model } = authorActor(context, input.author);
184
194
  const subject = input.subject === undefined ? undefined : requiredThing(database, input.subject, "the subject");
@@ -201,11 +211,12 @@ export const memoriesStoreOver = (context) => {
201
211
  list: async (options = {}) => {
202
212
  const subject = options.subject === undefined ? undefined : requiredThing(database, options.subject);
203
213
  const project = options.project === undefined ? null : projectId(database, options.project);
214
+ const by = proposedBySql("m.author_type", "m.author_id", options.proposedBy);
204
215
  return database
205
216
  .prepare(`${NAMED} WHERE m.deleted_at IS NULL AND (? IS NULL OR (m.subject_type = ? AND m.subject_id = ?)) ` +
206
217
  "AND (? IS NULL OR m.project_id = ?) AND (? IS NULL OR m.kind = ?) AND (? IS NULL OR m.status = ?) AND (? IS NULL OR m.scope = ?) " +
207
- "ORDER BY m.created_at DESC, m.id DESC LIMIT ?")
208
- .all(subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, project, project, options.kind ?? null, options.kind ?? null, options.status ?? null, options.status ?? null, options.scope ?? null, options.scope ?? null, Math.min(Math.max(options.limit ?? 100, 1), 500))
218
+ `AND ${by.sql} ORDER BY m.created_at DESC, m.id DESC LIMIT ?`)
219
+ .all(subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, project, project, options.kind ?? null, options.kind ?? null, options.status ?? null, options.status ?? null, options.scope ?? null, options.scope ?? null, ...by.params, Math.min(Math.max(options.limit ?? 100, 1), 500))
209
220
  .map(memoryOf);
210
221
  },
211
222
  update: async (reference, change) => write(database, () => {
@@ -214,10 +225,22 @@ export const memoriesStoreOver = (context) => {
214
225
  database.prepare("UPDATE memories SET project_id = ?, updated_at = ? WHERE id = ?").run(project, now(), id);
215
226
  return memoryOf(rowOf(`memory:${id}`));
216
227
  }),
217
- confirm: async (reference) => setStatus(reference, "confirmed", true),
218
- markStale: async (reference) => setStatus(reference, "stale", false),
219
- remove: async (reference) => {
220
- const id = Number(rowOf(reference).id);
228
+ confirm: async (reference, by) => {
229
+ decided(reference, by);
230
+ return setStatus(reference, "confirmed", true);
231
+ },
232
+ markStale: async (reference, by) => {
233
+ decided(reference, by);
234
+ return setStatus(reference, "stale", false);
235
+ },
236
+ remove: async (reference, by) => {
237
+ const row = rowOf(reference);
238
+ assertMayWithdraw({
239
+ id: `memory:${row.id}`,
240
+ proposedBy: proposerOf(database, row.author_type, row.author_id),
241
+ proposed: row.status === "proposed",
242
+ }, by);
243
+ const id = Number(row.id);
221
244
  const links = erase(database, () => {
222
245
  keepHighestId(database, "memories", now());
223
246
  database.prepare("UPDATE memories SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
@@ -238,7 +261,7 @@ export const memoriesStoreOver = (context) => {
238
261
  throw new CliError("validation_error", "say what to find in the memories");
239
262
  const match = `normalized_text : (${words.map((word) => `"${word.replaceAll('"', '""')}"*`).join(" AND ")})`;
240
263
  return database
241
- .prepare("SELECT m.*, b.name AS author_name FROM memory_words w JOIN memories m ON m.id = w.rowid " +
264
+ .prepare("SELECT m.*, b.name AS author_name, b.kind AS author_kind FROM memory_words w JOIN memories m ON m.id = w.rowid " +
242
265
  "LEFT JOIN bots b ON m.author_type = 'bot' AND b.id = m.author_id WHERE memory_words MATCH ? " +
243
266
  "AND m.deleted_at IS NULL AND (? IS NULL OR (m.subject_type = ? AND m.subject_id = ?)) " +
244
267
  "AND (? IS NULL OR m.scope = ?) ORDER BY bm25(memory_words), m.id LIMIT ?")
@@ -1,4 +1,5 @@
1
1
  import type { Section } from "../../readers/types.js";
2
+ import { type Decider } from "../decisions.js";
2
3
  import type { CacheDatabase } from "../driver.js";
3
4
  import { type DocumentMetadata } from "./document-metadata.js";
4
5
  import { type ImportedDocuments } from "./imported-documents.js";
@@ -81,6 +82,8 @@ export interface LinkInput {
81
82
  evidence?: string;
82
83
  provenance?: string;
83
84
  confirmed?: boolean;
85
+ /** Who writes it: a confirmed link needs the owner. */
86
+ by?: Decider;
84
87
  }
85
88
  export interface FileNoteInput {
86
89
  folderId: string;
@@ -151,7 +154,8 @@ export interface NotesStore extends NoteSearch, ImportedDocuments {
151
154
  }>;
152
155
  addLink(input: LinkInput): Promise<Link>;
153
156
  links(options?: LinkListFilter): Promise<Link[]>;
154
- removeLink(id: string): Promise<{
157
+ /** Only the owner removes a link: it records no actor, so who proposed it cannot be checked. */
158
+ removeLink(id: string, by: Decider): Promise<{
155
159
  id: string;
156
160
  removed: true;
157
161
  }>;