@wirecat/cli-core 0.412.0 → 0.414.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.
@@ -5,6 +5,7 @@ import { excerpt } from "./excerpt.js";
5
5
  import { attachNeighbours, documentMatches, } from "./project-gathering-around.js";
6
6
  import { decodeCheckpoint, encodeCheckpoint, projectGatheringRequest, projectUnits, seenKey, } from "./project-gathering-request.js";
7
7
  const terminal = new Set(["done", "unavailable", "failed"]);
8
+ const PAGE = 20;
8
9
  const CONTEXTS = ["person-only", "sender", "linked-chat", "project"];
9
10
  const contextOf = (lane) => lane.kinds.includes("linked-chat")
10
11
  ? "linked-chat"
@@ -84,6 +85,12 @@ export const gatherProjectEvidence = async (ports, request) => {
84
85
  const oversized = new Map();
85
86
  let stopped = null;
86
87
  const open = (u) => !terminal.has(u.state) && u.state !== "read-limited";
88
+ const linkedLanes = new Set(plan.linkedChats.flatMap((c) => (c.lane === null ? [] : [c.lane])));
89
+ // One even share per linked chat plus one for the word and sender lanes, so one busy chat cannot fill a run; never
90
+ // below a page, so every chat still gets its whole first page.
91
+ const linkedLimit = Math.max(PAGE, Math.floor(plan.budgets.maxItems / (linkedLanes.size + 1)));
92
+ const linkedTaken = new Map();
93
+ const capped = new Set();
87
94
  /** Records another lane finding a reference this run already returned; true when it had. */
88
95
  const again = (ref, lane) => {
89
96
  const known = byRef.get(ref);
@@ -133,11 +140,13 @@ export const gatherProjectEvidence = async (ports, request) => {
133
140
  byRef.set(item.ref, entry);
134
141
  items.push(entry);
135
142
  usage.items++;
143
+ if (linkedLanes.has(lane.id))
144
+ linkedTaken.set(lane.id, (linkedTaken.get(lane.id) ?? 0) + 1);
136
145
  };
137
146
  // A resumed run starts at its shallowest open unit: below it no unit is due, and an empty round would end the run.
138
147
  const shallowest = progress.reduce((low, u) => (open(u) ? Math.min(low, u.pages) : low), Number.POSITIVE_INFINITY);
139
148
  for (let round = shallowest; !stopped; round++) {
140
- const due = progress.flatMap((u, index) => (open(u) && u.pages <= round ? [index] : []));
149
+ const due = progress.flatMap((u, index) => open(u) && u.pages <= round && !capped.has(units[index]?.lane.id ?? "") ? [index] : []);
141
150
  if (!due.length)
142
151
  break;
143
152
  for (const index of due) {
@@ -145,6 +154,11 @@ export const gatherProjectEvidence = async (ports, request) => {
145
154
  if (!unit || !state)
146
155
  continue;
147
156
  signal?.throwIfAborted();
157
+ const room = linkedLanes.has(unit.lane.id) ? linkedLimit - (linkedTaken.get(unit.lane.id) ?? 0) : PAGE;
158
+ if (room <= 0) {
159
+ capped.add(unit.lane.id);
160
+ continue;
161
+ }
148
162
  stopped =
149
163
  usage.reads >= plan.budgets.maxReads
150
164
  ? "reads"
@@ -187,7 +201,7 @@ export const gatherProjectEvidence = async (ports, request) => {
187
201
  }
188
202
  };
189
203
  try {
190
- let page = await readPage(Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes), 20);
204
+ let page = await readPage(Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes), Math.min(PAGE, room));
191
205
  // A large record another lane already returned is not read again; its page already moves past it.
192
206
  const big = page.oversized && !again(page.oversized.ref, unit.lane) ? page.oversized : undefined;
193
207
  if (big && big.bytes <= CONTEXT_MAX_READ_BYTES) {
@@ -288,6 +302,8 @@ export const gatherProjectEvidence = async (ports, request) => {
288
302
  : units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
289
303
  ? "unavailable"
290
304
  : "in-scope",
305
+ items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0),
306
+ capped: lane !== null && capped.has(lane),
291
307
  })),
292
308
  fetch: fetched.map((f, index) => ({ source: index, ...f })),
293
309
  index: indexed,
@@ -12,8 +12,11 @@ export const accountPeopleOver = (context) => {
12
12
  return {
13
13
  savePeople: async (key, people) => write(database, () => {
14
14
  const accountKey = accountPk(key);
15
- for (const person of people)
15
+ for (const person of people) {
16
16
  identityPk(accountKey, key.provider, person.id, person.name, person);
17
+ if (person.email)
18
+ personLinks.joinByAddress(context, { provider: key.provider, id: person.id }, person.email, key.provider);
19
+ }
17
20
  }),
18
21
  contacts: async (key, options) => {
19
22
  const accountKey = findAccountPk(key);
@@ -14,6 +14,12 @@ export declare const linkIdentityToPerson: (context: StoreContext, identityPk: n
14
14
  export declare const linkIdentities: (context: StoreContext, person: IdentityRef, other: IdentityRef, options: LinkOptions) => PersonRecord;
15
15
  /** Moves every identity of `from` to `to`, as `linkIdentities` does once it has found both people. */
16
16
  export declare const mergePerson: (context: StoreContext, from: number, to: number, options: LinkOptions) => void;
17
+ /**
18
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
19
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
20
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
21
+ */
22
+ export declare const joinByAddress: (context: StoreContext, ref: IdentityRef, address: string, by: string) => void;
17
23
  /** Gives the identity a person of its own again; the ones left keep theirs. */
18
24
  export declare const unlinkIdentity: (context: StoreContext, ref: IdentityRef, options: LinkOptions) => PersonRecord;
19
25
  /** Messages that name the person: by id where the messenger said so, or by `@username` in the text. */
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, isNull, sql } from "./drizzle/core.js";
3
+ import { identityOf } from "./identities.js";
3
4
  import { resolvePersonLinks } from "./person-resolution.js";
4
5
  import { accountIdentities, accounts, chats, identities, identityLinkEvents, identityLinks, messages, persons, } from "./schema.js";
5
6
  import { hitsWhere } from "./search.js";
@@ -135,6 +136,32 @@ export const mergePerson = (context, from, to, options) => {
135
136
  for (const { identityPk } of together)
136
137
  move(context, identityPk, from, to, options);
137
138
  };
139
+ /** The owner once moved one of the two identities away from a person the other belonged to. */
140
+ const separatedByOwner = ({ database }, one, other) => database
141
+ .prepare(`SELECT 1 FROM identity_link_events moved
142
+ JOIN identity_link_events held ON held.to_person_id = moved.from_person_id
143
+ WHERE moved.author = 'owner' AND ((moved.identity_id = ? AND held.identity_id = ?)
144
+ OR (moved.identity_id = ? AND held.identity_id = ?))
145
+ LIMIT 1`)
146
+ .get(one, other, other, one) !== undefined;
147
+ /**
148
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
149
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
150
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
151
+ */
152
+ export const joinByAddress = (context, ref, address, by) => {
153
+ const key = address.trim().toLowerCase();
154
+ if (!/^[^\s@]+@[^\s@]+$/.test(key) || ref.provider === "email")
155
+ return;
156
+ const found = required(context, ref);
157
+ if (!identityRow(context, { provider: "email", id: key }))
158
+ identityOf(context, "email", key, null);
159
+ const contact = required(context, { provider: "email", id: key });
160
+ if (contact.personPk === found.personPk || separatedByOwner(context, found.pk, contact.pk))
161
+ return;
162
+ mergePerson(context, found.personPk, contact.personPk, { method: "same-email", by });
163
+ resolvePersonLinks(context.database, recordOf(context, contact.personPk).identities.flatMap(({ name, username }) => [name, username]));
164
+ };
138
165
  /** Gives the identity a person of its own again; the ones left keep theirs. */
139
166
  export const unlinkIdentity = (context, ref, options) => {
140
167
  const found = required(context, ref);
@@ -32,6 +32,8 @@ export interface PersonFacts {
32
32
  username?: string | null;
33
33
  isBot?: boolean | null;
34
34
  description?: string | null;
35
+ /** An address the provider shows for them: they become one person with the mail contact of that exact address. */
36
+ email?: string | null;
35
37
  }
36
38
  /** One identity in one messenger, by the messenger's own id. `provider` is any string: `email` is one. */
37
39
  export interface IdentityRef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.412.0",
3
+ "version": "0.414.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",
@@ -128,6 +128,10 @@
128
128
  "./update": {
129
129
  "types": "./dist/update/index.d.ts",
130
130
  "default": "./dist/update/index.js"
131
+ },
132
+ "./oauth": {
133
+ "types": "./dist/oauth/index.d.ts",
134
+ "default": "./dist/oauth/index.js"
131
135
  }
132
136
  },
133
137
  "scripts": {