@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
@@ -4,6 +4,7 @@ import { googleDocumentLinks } from "../../domain/google-documents.js";
4
4
  import { DOCUMENT_ACCOUNT_PROVIDERS, filesAccountSql, folderProviderOf, folderProviderSql, isDocumentAccountProvider, } from "../../domain/providers.js";
5
5
  import { parseReference } from "../../domain/references.js";
6
6
  import { normalizeTag } from "../../domain/tags.js";
7
+ import { assertMayWithdraw } from "../decisions.js";
7
8
  import { fold } from "../normalize.js";
8
9
  import { ulid } from "../ulid.js";
9
10
  import { ownerPerson } from "./actors.js";
@@ -149,6 +150,20 @@ export const resolvePersonLinks = (database, names) => {
149
150
  if (check.get(key))
150
151
  resolveKey(database, key);
151
152
  };
153
+ /** A confirmed link is the owner's statement, of any kind; an agent's link is written unconfirmed. */
154
+ const ownerLink = (input) => {
155
+ if (input.confirmed === false)
156
+ return input;
157
+ if (input.by === undefined)
158
+ throw new CliError("validation_error", "a confirmed link names who writes it: the owner", {
159
+ reason: "proposal.decider-required",
160
+ });
161
+ if (input.by !== "owner")
162
+ throw new CliError("permission_error", "an agent's link is written unconfirmed; only the owner confirms one", {
163
+ reason: "proposal.owner-only",
164
+ });
165
+ return input;
166
+ };
152
167
  /** Writes one link; an identical resolved link is answered instead of doubled. */
153
168
  export const insertLink = (context, input) => {
154
169
  const { database, now } = context;
@@ -592,7 +607,7 @@ export const notesStoreOver = (context) => {
592
607
  database.prepare("DELETE FROM notes WHERE id = ?").run(Number(bare));
593
608
  return { id, removed: true };
594
609
  }),
595
- addLink: async (input) => write(database, () => insertLink(context, input)),
610
+ addLink: async (input) => write(database, () => insertLink(context, ownerLink(input))),
596
611
  links: async (options = {}) => {
597
612
  const pair = (reference) => {
598
613
  if (reference === undefined)
@@ -617,7 +632,8 @@ export const notesStoreOver = (context) => {
617
632
  .all(from.type, from.type, from.id, to.type, to.type, to.id, options.unresolved ? 1 : 0, ...filters.map(({ value }) => value))
618
633
  .map((row) => linkOf(database, row));
619
634
  },
620
- removeLink: async (id) => write(database, () => {
635
+ removeLink: async (id, by) => write(database, () => {
636
+ assertMayWithdraw({ id: `link:${id}`, proposedBy: { type: "unknown" }, proposed: false }, by);
621
637
  if (!/^\d+$/.test(id) || !database.prepare("DELETE FROM links WHERE id = ?").run(Number(id)).changes)
622
638
  throw new CliError("not_found", `no link ${id} in the local store`);
623
639
  return { id, removed: true };
@@ -1,3 +1,4 @@
1
+ import { type DecisionPolicy } from "../decisions.js";
1
2
  import { type StoreContext } from "./open.js";
2
3
  export interface StoreOptions {
3
4
  /** Open an existing compatible store without maintenance; SQLite refuses writes. */
@@ -11,6 +12,8 @@ export interface StoreOptions {
11
12
  version?: string;
12
13
  /** Where the notice of an upgrade that locks other CLIs out goes; stderr, since stdout may carry JSON or MCP. */
13
14
  warn?: (line: string) => void;
15
+ /** The host's decision policy (its `agent_decisions`); the default lets an agent decide with a reason. */
16
+ decisions?: DecisionPolicy;
14
17
  }
15
18
  export declare const BACKFILL_ON_OPEN = 5000;
16
- export declare const openStoreContext: ({ path, env, now, command, version, warn, readOnly, }?: StoreOptions) => Promise<StoreContext>;
19
+ export declare const openStoreContext: ({ path, env, now, command, version, warn, readOnly, decisions, }?: StoreOptions) => Promise<StoreContext>;
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { CliError } from "@wirecat/cli-base";
4
+ import { DEFAULT_DECISION_POLICY } from "../decisions.js";
4
5
  import { migrate } from "../migrations.js";
5
6
  import { storeCapable } from "../open.js";
6
7
  import { storePath } from "../path.js";
@@ -31,7 +32,7 @@ const createWritable = (file) => {
31
32
  throw new CliError("permission_error", "the message store cannot be written from here; run where it is writable, or set MESSAGING_STORE", { reason: "store.not-writable" });
32
33
  }
33
34
  };
34
- export const openStoreContext = async ({ path, env, now = Date.now, command, version, warn = (line) => process.stderr.write(`${line}\n`), readOnly = false, } = {}) => {
35
+ export const openStoreContext = async ({ path, env, now = Date.now, command, version, warn = (line) => process.stderr.write(`${line}\n`), readOnly = false, decisions = DEFAULT_DECISION_POLICY, } = {}) => {
35
36
  if (typeof readOnly !== "boolean")
36
37
  throw new CliError("validation_error", "readOnly must be a boolean");
37
38
  await storeCapable();
@@ -68,7 +69,12 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
68
69
  });
69
70
  }
70
71
  loadProviders(database);
71
- return { ...opened, now, sharedConfig: sharedConfigPath(file, env ?? process.env) };
72
+ return {
73
+ ...opened,
74
+ now,
75
+ sharedConfig: sharedConfigPath(file, env ?? process.env),
76
+ decisions,
77
+ };
72
78
  }
73
79
  catch (error) {
74
80
  database.close();
@@ -1,3 +1,4 @@
1
+ import type { DecisionPolicy } from "../decisions.js";
1
2
  import { type CacheDatabase } from "../driver.js";
2
3
  import type { SQLiteAsyncDatabase } from "./drizzle/core.js";
3
4
  /** Drizzle over the store's connection. Synchronous inside: a transaction never awaits (plan D3). */
@@ -11,6 +12,8 @@ export interface StoreContext extends OpenedSqlite {
11
12
  now: () => number;
12
13
  /** The owner's shared config file, whose `task_categories` edits the store's list; none in a bare context. */
13
14
  sharedConfig?: string;
15
+ /** Who may decide proposals, set once by the host at open; every decide method reads it. */
16
+ decisions?: DecisionPolicy;
14
17
  }
15
18
  /**
16
19
  * One SQLite connection, seen two ways: the `CacheDatabase` seam the hand-written SQL and the
@@ -1,5 +1,6 @@
1
+ import { type Decider, type Proposer } from "../decisions.js";
1
2
  import type { AccountKey } from "../types.js";
2
- import type { Actor } from "./actors.js";
3
+ import { type Actor, type ProposedByFilter } from "./actors.js";
3
4
  import { type Author } from "./memories.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  export declare const PROPOSAL_STATUSES: readonly ["proposed", "approved", "rejected", "executed", "failed"];
@@ -18,11 +19,12 @@ export interface ProposedAction {
18
19
  payload: unknown;
19
20
  reason: string | null;
20
21
  status: (typeof PROPOSAL_STATUSES)[number];
21
- /** `name` is a bot's handle; `null` for a person. */
22
+ /** `name` is a bot's handle and `kind` whether it is an agent or a rule; both `null` for a person. */
22
23
  proposedBy: {
23
24
  type: Actor["type"];
24
25
  id: string;
25
26
  name: string | null;
27
+ kind: "agent" | "rule" | null;
26
28
  };
27
29
  decidedBy: {
28
30
  type: Actor["type"];
@@ -50,14 +52,20 @@ export interface ProposedActionsStore {
50
52
  pending(options?: {
51
53
  limit?: number;
52
54
  }): Promise<ProposedAction[]>;
53
- /** Newest first; every status unless one is named. */
55
+ /** Newest first; every status unless one is named, every kind unless `kinds` names some or `except` leaves some out. */
54
56
  list(options?: {
55
57
  status?: ProposedAction["status"];
56
58
  limit?: number;
59
+ kinds?: readonly string[];
60
+ except?: readonly string[];
61
+ proposedBy?: ProposedByFilter;
62
+ /** A `project:<id>` the action targets or its payload names as `to`, `from` or `project`. */
63
+ project?: string;
57
64
  }): Promise<ProposedAction[]>;
58
- /** `result` records what the approval made, such as the task it became. */
59
- approve(id: string, result?: unknown): Promise<ProposedAction>;
60
- reject(id: string): Promise<ProposedAction>;
65
+ /** `result` records what the approval made, such as the task it became. `by` must be allowed to decide it. */
66
+ approve(id: string, by: Decider, result?: unknown): Promise<ProposedAction>;
67
+ /** `reason` is kept as the result: why it was turned down. */
68
+ reject(id: string, by: Decider, reason?: string): Promise<ProposedAction>;
61
69
  /** An approved action the adapter carried out, with what the provider answered. */
62
70
  executed(id: string, result?: unknown): Promise<ProposedAction>;
63
71
  failed(id: string, error: string): Promise<ProposedAction>;
@@ -65,10 +73,15 @@ export interface ProposedActionsStore {
65
73
  }
66
74
  type ProposalOperations = Omit<{
67
75
  [K in keyof ProposedActionsStore]: ProposedActionsStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
68
- }, "approve"> & {
69
- /** `by` records an agent deciding for the owner; the public store always records the owner. */
76
+ }, "approve" | "reject"> & {
77
+ /** Records a decision already checked; `by` records an agent deciding for the owner. */
70
78
  approve(id: string, result?: unknown, by?: Author): ProposedAction;
79
+ reject(id: string, by?: Author, result?: unknown): ProposedAction;
80
+ /** Who proposed it, for `mayDecide`. */
81
+ proposer(id: string): Proposer;
71
82
  };
83
+ /** Who proposed an action, for `mayDecide`. */
84
+ export declare const proposerOfAction: ({ proposedBy }: ProposedAction) => Proposer;
72
85
  export declare const proposalOperationsOver: (context: StoreContext) => ProposalOperations;
73
86
  export declare const proposedActionsStoreOver: (context: StoreContext) => ProposedActionsStore;
74
87
  export {};
@@ -1,6 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { MEETING_FINDINGS_KIND } from "../../meetings/index.js";
3
+ import { assertMayDecide, deciderAuthor } from "../decisions.js";
3
4
  import { EVIDENCE_FINDINGS_KIND } from "../evidence-findings.js";
5
+ import { proposedBySql } from "./actors.js";
4
6
  import { authorActor } from "./memories.js";
5
7
  import { requiredThing } from "./notes.js";
6
8
  import { referenceOfThing, storedThing } from "./things.js";
@@ -15,8 +17,14 @@ const json = (value) => (value == null ? null : JSON.parse(String(value)));
15
17
  */
16
18
  const STATUS = `CASE WHEN p.status = 'approved' AND p.kind IN ('${MEETING_FINDINGS_KIND}', '${EVIDENCE_FINDINGS_KIND}') ` +
17
19
  "AND json_array_length(p.result, '$.accepted') = 0 THEN 'rejected' ELSE p.status END";
18
- const NAMED = `SELECT p.*, ${STATUS} AS shown_status, b.name AS proposer_name FROM proposed_actions p ` +
20
+ const NAMED = `SELECT p.*, ${STATUS} AS shown_status, b.name AS proposer_name, b.kind AS proposer_kind FROM proposed_actions p ` +
19
21
  "LEFT JOIN bots b ON p.proposed_by_type = 'bot' AND b.id = p.proposed_by_id";
22
+ /** Who proposed an action, for `mayDecide`. */
23
+ export const proposerOfAction = ({ proposedBy }) => proposedBy.type === "person"
24
+ ? { type: "person", id: proposedBy.id }
25
+ : proposedBy.name === null
26
+ ? { type: "unknown" }
27
+ : { type: "bot", id: proposedBy.id, name: proposedBy.name, kind: proposedBy.kind ?? "agent" };
20
28
  export const proposalOperationsOver = (context) => {
21
29
  const { database, now } = context;
22
30
  const actionOf = (row) => {
@@ -37,6 +45,11 @@ export const proposalOperationsOver = (context) => {
37
45
  type: row.proposed_by_type,
38
46
  id: String(row.proposed_by_id),
39
47
  name: row.proposer_name == null ? null : String(row.proposer_name),
48
+ kind: row.proposed_by_type !== "bot"
49
+ ? null
50
+ : row.proposer_kind === "rule" || row.proposer_name === "rule"
51
+ ? "rule"
52
+ : "agent",
40
53
  },
41
54
  decidedBy: row.decided_by_type == null
42
55
  ? null
@@ -108,16 +121,21 @@ export const proposalOperationsOver = (context) => {
108
121
  .prepare(`${NAMED} WHERE p.status = 'proposed' ORDER BY p.created_at, p.id LIMIT ?`)
109
122
  .all(Math.min(Math.max(limit, 1), 500))
110
123
  .map(actionOf),
111
- list: ({ status, limit = 100 } = {}) => {
124
+ list: ({ status, limit = 100, kinds, except, proposedBy, project } = {}) => {
112
125
  if (status !== undefined && !PROPOSAL_STATUSES.includes(status))
113
126
  throw new CliError("validation_error", `a status is one of ${PROPOSAL_STATUSES.join(", ")}`);
127
+ const by = proposedBySql("p.proposed_by_type", "p.proposed_by_id", proposedBy);
114
128
  return database
115
- .prepare(`${NAMED} WHERE (? IS NULL OR ${STATUS} = ?) ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
116
- .all(status ?? null, status ?? null, Math.min(Math.max(limit, 1), 500))
129
+ .prepare(`${NAMED} WHERE (? IS NULL OR ${STATUS} = ?) AND (? IS NULL OR p.kind IN (SELECT value FROM json_each(?))) ` +
130
+ "AND p.kind NOT IN (SELECT value FROM json_each(?)) AND (? IS NULL OR (p.target_type = 'project' AND " +
131
+ "'project:' || p.target_id = ?) OR json_extract(p.payload, '$.to') = ? OR json_extract(p.payload, '$.from') = ? " +
132
+ `OR json_extract(p.payload, '$.project') = ?) AND ${by.sql} ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
133
+ .all(status ?? null, status ?? null, kinds === undefined ? null : 1, JSON.stringify(kinds ?? []), JSON.stringify(except ?? []), ...Array(5).fill(project ?? null), ...by.params, Math.min(Math.max(limit, 1), 500))
117
134
  .map(actionOf);
118
135
  },
119
136
  approve: (id, result, by) => decide(id, "approved", result, by),
120
- reject: (id) => decide(id, "rejected"),
137
+ reject: (id, by, result) => decide(id, "rejected", result, by),
138
+ proposer: (id) => proposerOfAction(actionOf(rowOf(id))),
121
139
  executed: (id, result) => move(id, ["approved"], "executed", ", executed_at = ?, result = ?", [
122
140
  now(),
123
141
  result === undefined ? null : JSON.stringify(result),
@@ -161,13 +179,15 @@ export const proposedActionsStoreOver = (context) => {
161
179
  get: async (id) => operations.get(id),
162
180
  pending: async (options) => operations.pending(options),
163
181
  list: async (options) => operations.list(options),
164
- approve: async (id, result) => {
182
+ approve: async (id, by, result) => {
165
183
  generic(id);
166
- return operations.approve(id, result);
184
+ assertMayDecide({ id, proposedBy: operations.proposer(id) }, by, context.decisions);
185
+ return operations.approve(id, result, deciderAuthor(by));
167
186
  },
168
- reject: async (id) => {
187
+ reject: async (id, by, reason) => {
169
188
  generic(id);
170
- return operations.reject(id);
189
+ assertMayDecide({ id, proposedBy: operations.proposer(id) }, by, context.decisions);
190
+ return operations.reject(id, deciderAuthor(by), reason?.trim() ? { reason: reason.trim() } : undefined);
171
191
  },
172
192
  executed: async (id, result) => {
173
193
  generic(id);
@@ -898,14 +898,25 @@ export declare const messagesWindow: (context: StoreContext, chatKey: number, {
898
898
  before: number;
899
899
  after: number;
900
900
  }) => Message[];
901
- export declare const messagePage: (context: StoreContext, chatKey: number, { limit, before: anchorId, since, threadId, outgoing, starred, }: {
901
+ /**
902
+ * `before` pages back from a message, newest page first. `after` reads forward from a message or strictly after a
903
+ * moment (epoch ms), oldest first. `until` (epoch ms, inclusive) ends either read.
904
+ */
905
+ export interface MessagePageWindow {
902
906
  limit: number;
903
907
  before?: Id;
908
+ after?: {
909
+ id: Id;
910
+ } | {
911
+ time: number;
912
+ };
904
913
  since?: string;
914
+ until?: number;
905
915
  threadId?: Id;
906
916
  outgoing?: boolean;
907
917
  starred?: boolean;
908
- }) => Page<Message>;
918
+ }
919
+ export declare const messagePage: (context: StoreContext, chatKey: number, { limit, before: beforeId, after: start, since, until, threadId, outgoing, starred }: MessagePageWindow) => Page<Message>;
909
920
  export declare const around: (context: StoreContext, chatKey: number | undefined, messageId: Id, { before: earlier, after: later }: {
910
921
  before: number;
911
922
  after: number;
@@ -33,6 +33,7 @@ export const selectMessages = ({ orm }) => orm
33
33
  .innerJoin(chats, eq(chats.id, messages.chatId))
34
34
  .leftJoin(identities, eq(identities.id, messages.senderIdentityId));
35
35
  export const newestFirst = [desc(messages.sentAt), desc(messages.id)];
36
+ const oldestFirst = [asc(messages.sentAt), asc(messages.id)];
36
37
  export const before = (sentAt, pk) => sql `(${messages.sentAt}, ${messages.id}) < (${sentAt}, ${pk})`;
37
38
  const after = (sentAt, pk) => sql `(${messages.sentAt}, ${messages.id}) > (${sentAt}, ${pk})`;
38
39
  const live = (chatKey, ...conditions) => and(eq(messages.chatId, chatKey), isNull(messages.deletedAt), ...conditions);
@@ -92,7 +93,9 @@ export const messagesWindow = (context, chatKey, { at, before: earlier, after: l
92
93
  .all();
93
94
  return toMessages(context, [...older.reverse(), ...newer]);
94
95
  };
95
- export const messagePage = (context, chatKey, { limit, before: anchorId, since, threadId, outgoing, starred, }) => {
96
+ export const messagePage = (context, chatKey, { limit, before: beforeId, after: start, since, until, threadId, outgoing, starred }) => {
97
+ const forward = start !== undefined;
98
+ const anchorId = forward ? ("id" in start ? start.id : undefined) : beforeId;
96
99
  const anchor = anchorId === undefined
97
100
  ? undefined
98
101
  : context.orm
@@ -104,7 +107,7 @@ export const messagePage = (context, chatKey, { limit, before: anchorId, since,
104
107
  throw new CliError("not_found", `message ${anchorId} is not in the local copy of this chat`);
105
108
  }
106
109
  const rows = selectMessages(context)
107
- .where(live(chatKey, anchor ? before(anchor.sentAt, anchor.pk) : undefined, since === undefined ? undefined : gte(messages.sentAt, toMs(since)), threadId === undefined ? undefined : eq(messages.threadExternalId, threadId), outgoing === undefined
110
+ .where(live(chatKey, anchor ? (forward ? after : before)(anchor.sentAt, anchor.pk) : undefined, start !== undefined && "time" in start ? gt(messages.sentAt, start.time) : undefined, since === undefined ? undefined : gte(messages.sentAt, toMs(since)), until === undefined ? undefined : lte(messages.sentAt, until), threadId === undefined ? undefined : eq(messages.threadExternalId, threadId), outgoing === undefined
108
111
  ? undefined
109
112
  : outgoing
110
113
  ? eq(messages.outgoing, 1)
@@ -113,10 +116,11 @@ export const messagePage = (context, chatKey, { limit, before: anchorId, since,
113
116
  : starred
114
117
  ? sql `${messages.starred} = 1`
115
118
  : or(eq(messages.starred, 0), isNull(messages.starred))))
116
- .orderBy(...newestFirst)
119
+ .orderBy(...(forward ? oldestFirst : newestFirst))
117
120
  .limit(limit + 1)
118
121
  .all();
119
- return { items: toMessages(context, rows.slice(0, limit)).reverse(), hasMore: rows.length > limit };
122
+ const page = toMessages(context, rows.slice(0, limit));
123
+ return { items: forward ? page : page.reverse(), hasMore: rows.length > limit };
120
124
  };
121
125
  export const around = (context, chatKey, messageId, { before: earlier, after: later }) => {
122
126
  const anchor = chatKey === undefined
@@ -0,0 +1,34 @@
1
+ import { type Decider } from "../decisions.js";
2
+ import type { Author } from "./memories.js";
3
+ import { type Link } from "./notes.js";
4
+ import type { StoreContext } from "./open.js";
5
+ import { type ProposedAction } from "./proposed-actions.js";
6
+ import { type Thing } from "./things.js";
7
+ /** `relationship` proposals in `proposed_actions`: an agent's or a rule's, and the rejected ones a scan skips. */
8
+ export declare const relationProposalsOver: (context: StoreContext) => {
9
+ /** The same pending pair returns the proposal already held. */
10
+ propose: (from: Thing, input: {
11
+ from: string;
12
+ to: string;
13
+ kind: string;
14
+ role?: string;
15
+ evidence?: string[];
16
+ reason?: string;
17
+ }, by: Exclude<Author, "owner">) => ProposedAction;
18
+ rejected: (link: Link, by: Decider, reason?: string) => void;
19
+ /**
20
+ * Decides a stored `relationship` or `key_document` proposal in one transaction: the confirmed link and the
21
+ * decision land together or not at all.
22
+ */
23
+ decideProposal: (id: string, decision: LinkDecision) => {
24
+ proposal: ProposedAction;
25
+ link: Link | null;
26
+ };
27
+ /** Decides an unconfirmed link written before proposals were stored; only the owner may, as its author is unknown. */
28
+ decideLink: (id: string, decision: LinkDecision) => Link | null;
29
+ };
30
+ export interface LinkDecision {
31
+ confirm: boolean;
32
+ by: Decider;
33
+ reason?: string;
34
+ }
@@ -0,0 +1,114 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { assertMayDecide, deciderAuthor } from "../decisions.js";
3
+ import { actorOfOrigin, botNamed, ownerPerson } from "./actors.js";
4
+ import { insertLink, linkOf } from "./notes.js";
5
+ import { proposalOperationsOver, proposerOfAction } from "./proposed-actions.js";
6
+ import { thingOf } from "./things.js";
7
+ import { write } from "./transaction.js";
8
+ /** `relationship` proposals in `proposed_actions`: an agent's or a rule's, and the rejected ones a scan skips. */
9
+ export const relationProposalsOver = (context) => {
10
+ const { database, now } = context;
11
+ const operations = proposalOperationsOver(context);
12
+ /** The rejected `relationship` proposal a removed link leaves behind, so a scan never proposes it again. */
13
+ const rejected = (link, by, reason) => {
14
+ const at = now();
15
+ const proposed = link.confirmed ? ownerPerson(database) : actorOfOrigin(database, "agent");
16
+ const decided = by === "owner" ? ownerPerson(database) : botNamed(database, by.bot, at);
17
+ const why = [
18
+ `${link.confirmed ? "removed" : "rejected"} by ${by === "owner" ? "the owner" : `agent:${by.bot}`}`,
19
+ by === "owner" ? undefined : by.reason.trim(),
20
+ reason?.trim() || undefined,
21
+ ].filter(Boolean);
22
+ const from = thingOf(database, link.from);
23
+ database
24
+ .prepare("INSERT INTO proposed_actions (kind, target_type, target_id, payload, reason, status, proposed_by_type, " +
25
+ "proposed_by_id, decided_by_type, decided_by_id, decided_at, result, created_at, updated_at) " +
26
+ "VALUES ('relationship', ?, ?, ?, ?, 'rejected', ?, ?, ?, ?, ?, ?, ?, ?)")
27
+ .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
+ };
29
+ return {
30
+ /** The same pending pair returns the proposal already held. */
31
+ propose: (from, input, by) => {
32
+ const evidence = input.evidence ?? [];
33
+ if (!Array.isArray(evidence) || evidence.length > 5 || evidence.some((ref) => typeof ref !== "string"))
34
+ throw new CliError("validation_error", "a relationship proposal cites up to 5 references");
35
+ return write(database, () => {
36
+ const held = database
37
+ .prepare("SELECT id FROM proposed_actions WHERE kind='relationship' AND status='proposed' AND target_type=? AND target_id=? " +
38
+ "AND json_extract(payload,'$.to')=? AND json_extract(payload,'$.kind')=? ORDER BY id LIMIT 1")
39
+ .get(from.type, from.id, input.to, input.kind);
40
+ if (held)
41
+ return operations.get(String(held.id));
42
+ return operations.propose({
43
+ kind: "relationship",
44
+ by,
45
+ target: input.from,
46
+ payload: {
47
+ from: input.from,
48
+ to: input.to,
49
+ kind: input.kind,
50
+ ...(input.role === undefined ? {} : { role: input.role }),
51
+ evidence,
52
+ },
53
+ ...(input.reason === undefined ? {} : { reason: input.reason }),
54
+ });
55
+ });
56
+ },
57
+ rejected: (link, by, reason) => rejected(link, by, reason),
58
+ /**
59
+ * Decides a stored `relationship` or `key_document` proposal in one transaction: the confirmed link and the
60
+ * decision land together or not at all.
61
+ */
62
+ decideProposal: (id, decision) => write(database, () => {
63
+ const proposal = operations.get(id);
64
+ if (proposal.kind !== "relationship" && proposal.kind !== "key_document")
65
+ throw new CliError("validation_error", `${id} is a ${proposal.kind} proposal, not a link`);
66
+ assertMayDecide({ id, proposedBy: proposerOfAction(proposal) }, decision.by, context.decisions);
67
+ const author = deciderAuthor(decision.by);
68
+ const reason = decision.reason?.trim() || undefined;
69
+ if (!decision.confirm)
70
+ return { proposal: operations.reject(id, author, reason === undefined ? undefined : { reason }), link: null };
71
+ const payload = proposal.payload;
72
+ const key = proposal.kind === "key_document";
73
+ const link = insertLink(context, {
74
+ from: String(key ? payload.ref : payload.from),
75
+ to: String(key ? payload.project : payload.to),
76
+ kind: key ? "related-to" : (payload.kind ?? "member-of"),
77
+ ...(key ? { role: "key" } : payload.role === undefined ? {} : { role: payload.role }),
78
+ ...(proposal.reason === null ? {} : { evidence: proposal.reason }),
79
+ ...decidedOrigin(decision.by),
80
+ });
81
+ return {
82
+ proposal: operations.approve(id, { link: link.id, ...(reason === undefined ? {} : { reason }) }, author),
83
+ link,
84
+ };
85
+ }),
86
+ /** Decides an unconfirmed link written before proposals were stored; only the owner may, as its author is unknown. */
87
+ decideLink: (id, decision) => write(database, () => {
88
+ const row = /^\d+$/.test(id)
89
+ ? database.prepare("SELECT * FROM links WHERE id = ? AND confirmed = 0").get(Number(id))
90
+ : undefined;
91
+ if (!row)
92
+ throw new CliError("not_found", `no suggested link ${id}`);
93
+ assertMayDecide({ id: `link:${id}`, proposedBy: { type: "unknown" } }, decision.by, context.decisions);
94
+ const link = linkOf(database, row);
95
+ if (!decision.confirm) {
96
+ rejected(link, decision.by, decision.reason);
97
+ database.prepare("DELETE FROM links WHERE id = ?").run(Number(id));
98
+ return null;
99
+ }
100
+ database
101
+ .prepare("UPDATE links SET confirmed=1, source='owner', author='owner', updated_at=? WHERE id=?")
102
+ .run(now(), Number(id));
103
+ return linkOf(database, database.prepare("SELECT * FROM links WHERE id = ?").get(Number(id)));
104
+ }),
105
+ };
106
+ };
107
+ /** A link an agent confirmed for the owner says so in its provenance. */
108
+ const decidedOrigin = (by) => by === "owner"
109
+ ? { origin: "owner" }
110
+ : {
111
+ origin: "suggested",
112
+ confirmed: true,
113
+ provenance: `agent:${by.bot} for the owner: ${by.reason.trim()}`,
114
+ };
@@ -6,6 +6,7 @@ import { type AttachmentsStore } from "./attachments.js";
6
6
  import { type ChatsStore } from "./chats.js";
7
7
  import { type ConversationsStore } from "./conversations.js";
8
8
  import { type CountersStore } from "./counters.js";
9
+ import { type DecisionPolicy } from "./decisions.js";
9
10
  import type { EvidenceFindingsStore } from "./evidence-findings.js";
10
11
  import type { MeetingFindingsStore } from "./meeting-findings.js";
11
12
  import { type MentionsStore } from "./mentions.js";
@@ -95,6 +96,8 @@ export interface MessageStore {
95
96
  readonly memories: MemoriesStore;
96
97
  /** What an agent wants done outside the store, waiting for the owner's approval. */
97
98
  readonly proposedActions: ProposedActionsStore;
99
+ /** Who may decide proposals, fixed when the store was opened; the store's decide methods and the registry read it. */
100
+ readonly decisionPolicy: DecisionPolicy;
98
101
  /** One row per tool an agent called, never its arguments. */
99
102
  readonly agentActions: AgentActionsStore;
100
103
  /** Meetings and calendar events: the port `src/meetings/` defines. */
@@ -4,6 +4,7 @@ import { accountAttachmentsOver, attachmentsStoreOver, } from "./attachments.js"
4
4
  import { chatsStoreOver } from "./chats.js";
5
5
  import { conversationsStoreOver } from "./conversations.js";
6
6
  import { accountCountersOver, countersStoreOver } from "./counters.js";
7
+ import { DEFAULT_DECISION_POLICY } from "./decisions.js";
7
8
  import { accountMentionsOver, mentionsStoreOver } from "./mentions.js";
8
9
  import { accountMessagesOver, messagesStoreOver } from "./messages.js";
9
10
  import { accountPeopleOver, peopleStoreOver } from "./people.js";
@@ -207,6 +208,7 @@ const storeOver = (context) => {
207
208
  get memories() {
208
209
  return memories();
209
210
  },
211
+ decisionPolicy: context.decisions ?? DEFAULT_DECISION_POLICY,
210
212
  get proposedActions() {
211
213
  return proposedActions();
212
214
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.546.0",
3
+ "version": "0.547.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",