@wirecat/cli-core 0.505.0 → 0.507.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 (40) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/cli/messenger/backfill-command.js +26 -3
  3. package/dist/mail/sync.js +56 -14
  4. package/dist/oauth/keeper.js +2 -2
  5. package/dist/oauth/storage.d.ts +3 -2
  6. package/dist/oauth/storage.js +3 -3
  7. package/dist/search/lucene/meaning-parts.d.ts +14 -0
  8. package/dist/search/lucene/meaning-parts.js +38 -0
  9. package/dist/services/archive.d.ts +6 -0
  10. package/dist/services/archive.js +4 -0
  11. package/dist/services/messages-discovery.d.ts +17 -2
  12. package/dist/services/messages-discovery.js +63 -17
  13. package/dist/services/messages-meaning.js +9 -6
  14. package/dist/services/messages-search.d.ts +2 -1
  15. package/dist/services/notes-search.d.ts +0 -7
  16. package/dist/services/notes-search.js +5 -39
  17. package/dist/services/search-all.js +1 -1
  18. package/dist/store/index.d.ts +1 -0
  19. package/dist/store/queries.d.ts +2 -0
  20. package/dist/store/queries.js +1 -0
  21. package/dist/store/sqlite/emails.d.ts +4 -1
  22. package/dist/store/sqlite/emails.js +11 -1
  23. package/dist/store/sqlite/facts.js +7 -4
  24. package/dist/store/sqlite/follow-ups.js +4 -4
  25. package/dist/store/sqlite/lucene.d.ts +5 -3
  26. package/dist/store/sqlite/lucene.js +9 -7
  27. package/dist/store/sqlite/memories.d.ts +8 -0
  28. package/dist/store/sqlite/memories.js +21 -3
  29. package/dist/store/sqlite/message-chunks.d.ts +6 -0
  30. package/dist/store/sqlite/message-chunks.js +10 -0
  31. package/dist/store/sqlite/project-transfer/records.js +3 -2
  32. package/dist/store/sqlite/sync.d.ts +7 -1
  33. package/dist/store/sqlite/transaction.d.ts +2 -1
  34. package/dist/store/sqlite/transaction.js +12 -3
  35. package/dist/store/vectors.d.ts +13 -0
  36. package/dist/store/vectors.js +4 -0
  37. package/dist/terminal/sign-in-steps.js +2 -4
  38. package/dist/terminal/step-lines.d.ts +7 -0
  39. package/dist/terminal/step-lines.js +8 -0
  40. package/package.json +3 -3
@@ -6,7 +6,7 @@ import { plainWords } from "./pictures.js";
6
6
  * Discovery's meaning half: the default local model over each message's own vector. It runs only when the model is
7
7
  * on disk and vectors exist — never a download, never a remote model — and any failure leaves the words alone.
8
8
  */
9
- export const meaningLane = (deps) => async (question, accounts, shared) => {
9
+ export const meaningLane = (deps) => async (question, accounts, shared, within) => {
10
10
  const target = embeddingTarget(deps, DEFAULT_TEXT_MODEL);
11
11
  const skipped = (reason, step) => ({
12
12
  skipped: { reason, model: target.id, ...(step ? { step } : {}) },
@@ -24,14 +24,17 @@ export const meaningLane = (deps) => async (question, accounts, shared) => {
24
24
  try {
25
25
  const ranked = [];
26
26
  if (words) {
27
+ const pks = await within?.();
27
28
  const lookUp = async (embedder) => {
28
29
  const [query] = await embedder.embed([words], "query");
29
30
  for (const one of ready) {
30
- const near = await store.vectors.nearestMessages(one, {
31
- model: target.key,
32
- limit: MEANING_DEPTH,
33
- query: query,
34
- });
31
+ const near = pks
32
+ ? await store.vectors.scoreMessages(one, { model: target.key, query: query, pks })
33
+ : await store.vectors.nearestMessages(one, {
34
+ model: target.key,
35
+ limit: MEANING_DEPTH,
36
+ query: query,
37
+ });
35
38
  for (const { chatId, messageId, score } of near)
36
39
  ranked.push({ ...one, chatId, id: messageId, score });
37
40
  }
@@ -8,7 +8,8 @@ import type { SearchRefreshed } from "./search-refresh.js";
8
8
  export interface QueryMetadata {
9
9
  language: "lucene-v1";
10
10
  discovery?: {
11
- method: "lexical-partial";
11
+ /** `lexical-strict` when the query's own syntax (-, OR, NOT, groups…) chose the word hits, not a relaxed OR. */
12
+ method: "lexical-partial" | "lexical-strict";
12
13
  candidateDepth: number;
13
14
  candidates: number;
14
15
  repliesAdded: number;
@@ -169,14 +169,7 @@ export declare const noteSourceOfQuery: (query: string) => {
169
169
  source?: Note["source"];
170
170
  rest: string;
171
171
  };
172
- /**
173
- * What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
174
- * meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
175
- * wanted words is left, so any note found by meaning fits.
176
- */
177
172
  export declare const fieldsOfQuery: (root: QueryNode) => QueryNode | undefined;
178
- /** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
179
- export declare const meaningWords: (root: QueryNode) => string;
180
173
  /**
181
174
  * The most notes a filter may hold for meaning to rank all of them, each by its best chunk. Above it, meaning ranks
182
175
  * the nearest notes overall and keeps those inside the filter, so a narrow filter over a large store loses some. On
@@ -6,6 +6,7 @@ import { defaultThreads, isTextModelInstalled, openEmbedder, textModelsDirectory
6
6
  import { DEFAULT_TEXT_MODEL, meaningFloor, textModel, vectorModelKey } from "../embeddings/models.js";
7
7
  import { DEFAULT_IMAGE_MODEL, imageModel } from "../images/picture/models.js";
8
8
  import { entriesOfList, fitting } from "../search/account-list.js";
9
+ import { fieldsOfQuery as fieldsOf, meaningWords } from "../search/lucene/meaning-parts.js";
9
10
  import { validateFields } from "../search/lucene/registry.js";
10
11
  import { hasStems } from "../search/lucene/resolved.js";
11
12
  import { SEARCH_QUERY } from "../search/lucene/types.js";
@@ -234,44 +235,9 @@ export const noteSourceOfQuery = (query) => {
234
235
  }
235
236
  return { source, rest };
236
237
  };
237
- const WORD_FIELDS = new Set(["text", "exact"]);
238
- /**
239
- * What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
240
- * meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
241
- * wanted words is left, so any note found by meaning fits.
242
- */
243
- export const fieldsOfQuery = (root) => {
244
- let narrows = false;
245
- const kept = (node) => {
246
- if (node.kind === "boolean")
247
- return {
248
- ...node,
249
- clauses: node.clauses.map(({ occur, node }) => {
250
- if (occur !== "must_not")
251
- return { occur, node: kept(node) };
252
- narrows = true;
253
- return { occur, node };
254
- }),
255
- };
256
- if (!WORD_FIELDS.has(node.field)) {
257
- narrows = true;
258
- return node;
259
- }
260
- // `in:notes` holds for every note and document; an empty clause list would match nothing.
261
- return { kind: "predicate", field: "in", operator: "term", value: "notes", span: node.span };
262
- };
263
- const fields = kept(root);
264
- return narrows ? fields : undefined;
265
- };
266
- /** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
267
- export const meaningWords = (root) => {
268
- const wanted = (node) => node.kind === "boolean"
269
- ? node.clauses.flatMap(({ occur, node }) => (occur === "must_not" ? [] : wanted(node)))
270
- : WORD_FIELDS.has(node.field) && (node.operator === "term" || node.operator === "phrase")
271
- ? [node.value]
272
- : [];
273
- return wanted(root).join(" ").trim();
274
- };
238
+ /** `in:notes` holds for every note and document. */
239
+ const EVERY_NOTE = { kind: "predicate", field: "in", operator: "term", value: "notes" };
240
+ export const fieldsOfQuery = (root) => fieldsOf(root, EVERY_NOTE);
275
241
  const allOf = (nodes) => nodes.length <= 1
276
242
  ? nodes[0]
277
243
  : {
@@ -286,7 +252,7 @@ const allOf = (nodes) => nodes.length <= 1
286
252
  const matching = async (store, refs, root, scope) => {
287
253
  if (refs.length === 0)
288
254
  return new Map();
289
- const resolved = await resolvedQuery(store, root ?? { kind: "predicate", field: "in", operator: "term", value: "notes", span: { start: 0, end: 0 } }, scope.timezone);
255
+ const resolved = await resolvedQuery(store, root ?? { ...EVERY_NOTE, span: { start: 0, end: 0 } }, scope.timezone);
290
256
  const found = await store.notes.search({
291
257
  root: resolved.root,
292
258
  refs,
@@ -123,7 +123,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
123
123
  return byWords;
124
124
  }
125
125
  messagesBy = { by: "words and meaning", ...(lane.partly ? { meaningSkipped: lane.partly } : {}) };
126
- const near = await nearEligible(store, account, query, messenger, lane.ranked, filters ?? "");
126
+ const near = await nearEligible(store, account, query, messenger, lane.ranked, filters ? { text: filters } : {});
127
127
  // One list per resource, as notes give theirs, so messages get no second vote against mail.
128
128
  const fused = new Map();
129
129
  byWords.forEach((item, rank) => {
@@ -51,6 +51,7 @@ export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people
51
51
  export type { ProposalInput, ProposedAction, ProposedActionsStore } from "./sqlite/proposed-actions.js";
52
52
  export { PROPOSAL_STATUSES } from "./sqlite/proposed-actions.js";
53
53
  export { fillSearchIndex, resetSearchIndex, type SearchIndexFill, type SearchIndexState, searchIndexState, } from "./sqlite/search-index.js";
54
+ export type { SyncStateSave } from "./sqlite/sync.js";
54
55
  export type { TagCreationOptions, TagCreationReview } from "./sqlite/tag-creation.js";
55
56
  export type { TagKind, TagRow } from "./sqlite/tags.js";
56
57
  export type { SimilarSubject, SimilarTask, TaskDraft } from "./sqlite/task-candidates.js";
@@ -15,6 +15,8 @@ export interface QueriesStore {
15
15
  countQuery(execution: QueryExecution, by: QueryGrouping): Promise<QueryGroup[]>;
16
16
  /** How many messages match, counted no further than `cap + 1`; `undefined` when only a full search could tell. */
17
17
  countMatches(execution: QueryExecution, cap: number): Promise<number | undefined>;
18
+ /** The keys of the messages that match, no more than `cap + 1`; `undefined` when only a full search could tell. */
19
+ matchingPks(execution: QueryExecution, cap: number): Promise<number[] | undefined>;
18
20
  conversationEligibility(execution: QueryExecution): Promise<ConversationEligibility>;
19
21
  rankQuery(execution: QueryExecution, request: RankingRequest): Promise<RankedStoreFound>;
20
22
  rankingEvidence(execution: QueryExecution, request: RankingEvidenceRequest): Promise<RankedEvidence>;
@@ -14,6 +14,7 @@ export const queriesStoreOver = (context, stemmerFor) => {
14
14
  matchQuery: async (execution) => lucene.matchQuery(context, mailIndexed(execution)),
15
15
  countQuery: async (execution, by) => lucene.countQuery(context, mailIndexed(execution), by),
16
16
  countMatches: async (execution, cap) => lucene.countMatches(context, mailIndexed(execution), cap),
17
+ matchingPks: async (execution, cap) => lucene.matchingPks(context, mailIndexed(execution), cap),
17
18
  conversationEligibility: async (execution) => conversationEligibility(context, execution),
18
19
  rankQuery: async (execution, request) => rankQuery(context, execution, request),
19
20
  rankingEvidence: async (execution, request) => rankingEvidence(context, execution, request),
@@ -7,6 +7,7 @@ import { type EmailLocationInput, type FolderEmail } from "./email-locations.js"
7
7
  import { type MailRulesStore } from "./mail-rules.js";
8
8
  import { type AttachmentInput } from "./meetings.js";
9
9
  import type { StoreContext } from "./open.js";
10
+ import { type SyncStateSave } from "./sync.js";
10
11
  export type { EmailLocationInput, FolderEmail };
11
12
  export interface EmailAddress {
12
13
  address: string;
@@ -203,6 +204,8 @@ export interface SenderScore {
203
204
  /** Mail in tables of its own: threads, emails, recipients and mailboxes, keyed by account and Message-ID. */
204
205
  export interface MailStore {
205
206
  saveThread(input: ThreadSave): Promise<ThreadDetails>;
207
+ /** Several threads and sync state in one transaction: a slice of mail commits with its resume point, or not at all. */
208
+ saveThreads(inputs: ThreadSave[], states?: SyncStateSave[]): Promise<ThreadDetails[]>;
206
209
  /** Newest activity first. */
207
210
  threads(filter?: MailFilter): Promise<EmailThread[]>;
208
211
  thread(id: number): Promise<ThreadDetails | null>;
@@ -280,4 +283,4 @@ export declare const emailHitsByPk: (database: CacheDatabase, pks: number[]) =>
280
283
  * Indexes the queued emails as the notes are indexed, words and stems of the subject, then the body; then cuts
281
284
  * the threads they belong to into pieces for search by meaning.
282
285
  */
283
- export declare const mailStoreOver: ({ database, orm }: Pick<StoreContext, "database" | "orm">, stemmerFor?: (stemmers: Stemmers) => Stemmer) => MailStore;
286
+ export declare const mailStoreOver: ({ database, orm, now }: Pick<StoreContext, "database" | "orm" | "now">, stemmerFor?: (stemmers: Stemmers) => Stemmer) => MailStore;
@@ -10,6 +10,7 @@ import { ensurePerson } from "./identities.js";
10
10
  import { mailRulesOver } from "./mail-rules.js";
11
11
  import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
12
12
  import { stemmerCache } from "./stems.js";
13
+ import { writeState } from "./sync.js";
13
14
  import { write } from "./transaction.js";
14
15
  import { dropUnusedVectors } from "./vector-index.js";
15
16
  import { dot } from "./vectors.js";
@@ -415,12 +416,21 @@ export const emailHitsByPk = (database, pks) => {
415
416
  * Indexes the queued emails as the notes are indexed, words and stems of the subject, then the body; then cuts
416
417
  * the threads they belong to into pieces for search by meaning.
417
418
  */
418
- export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) => ({
419
+ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache()) => ({
419
420
  rules: mailRulesOver(database),
420
421
  async saveThread(input) {
421
422
  const id = write(database, () => saveThread(database, orm, input));
422
423
  return threadDetails(database, id);
423
424
  },
425
+ async saveThreads(inputs, states = []) {
426
+ const ids = write(database, () => {
427
+ const saved = inputs.map((input) => saveThread(database, orm, input));
428
+ for (const { accountId, name, value } of states)
429
+ writeState({ orm, now }, accountId, name, value);
430
+ return saved;
431
+ });
432
+ return ids.map((id) => threadDetails(database, id));
433
+ },
424
434
  async threads(filter = {}) {
425
435
  const [limit, offset] = page(filter);
426
436
  return database
@@ -3,7 +3,7 @@ import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
4
  import { evidenceSourcesOf } from "./evidence-sources.js";
5
5
  import { linkKind } from "./link-kinds.js";
6
- import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, linkEvidence, subjectOf, } from "./memories.js";
6
+ import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, keepHighestId, linkEvidence, nextId, subjectOf, } from "./memories.js";
7
7
  import { requiredThing } from "./notes.js";
8
8
  import { write } from "./transaction.js";
9
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";
@@ -80,10 +80,10 @@ export const factOperationsOver = (context) => {
80
80
  const { actor, model } = authorActor(context, input.by);
81
81
  const at = now();
82
82
  const made = Number(database
83
- .prepare("INSERT INTO facts (type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
83
+ .prepare("INSERT INTO facts (id, type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
84
84
  "author_type, author_id, model, scope, source, created_at, updated_at) " +
85
- "VALUES (?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
86
- .get(type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", at, at)?.id);
85
+ "VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
86
+ .get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", at, at)?.id);
87
87
  linkEvidence(context, { type: "fact", id: made }, input.evidence ?? [], owner ? "owner" : "agent");
88
88
  if (input.createdFrom !== undefined) {
89
89
  const from = requiredThing(database, input.createdFrom, "the source");
@@ -135,6 +135,9 @@ export const factOperationsOver = (context) => {
135
135
  const id = Number(row.id);
136
136
  const twin = row.source === "memory" && row.metadata != null ? migratedFrom(String(row.metadata)) : undefined;
137
137
  const links = erase(database, () => {
138
+ keepHighestId(database, "facts", now());
139
+ if (twin !== undefined)
140
+ keepHighestId(database, "memories", now());
138
141
  database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
139
142
  const count = dropLinksOf(database, { type: "fact", id });
140
143
  database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
@@ -17,14 +17,14 @@ export const laterSharingRare = (context, wanted, { rareAt, limit }) => {
17
17
  if (!wanted.length || !stemmers || !stemsState(database)?.ready)
18
18
  return wanted.map(() => []);
19
19
  const stemmer = stemmerFor(stemmers);
20
- // A temporary table: reading the index's own counts needs no migration, and it leaves with the connection.
21
- database.exec("CREATE VIRTUAL TABLE IF NOT EXISTS temp.message_stem_counts USING fts5vocab(main, message_stems, col)");
22
- const count = database.prepare("SELECT doc FROM temp.message_stem_counts WHERE term = ? AND col = 'stems'");
20
+ // Counted only up to one past `rareAt`, so a common word costs no more than a rare one. Not an fts5vocab table:
21
+ // creating one is a write, which a read-only connection refuses.
22
+ const count = database.prepare("SELECT count(*) AS doc FROM (SELECT rowid FROM message_stems WHERE message_stems MATCH ? LIMIT ?)");
23
23
  const counts = new Map();
24
24
  const countOf = (stem) => {
25
25
  let found = counts.get(stem);
26
26
  if (found === undefined) {
27
- found = Number(count.get(stem)?.doc ?? 0);
27
+ found = Number(count.get(`stems : "${stem.replaceAll('"', '""')}"`, rareAt + 1)?.doc ?? 0);
28
28
  counts.set(stem, found);
29
29
  }
30
30
  return found;
@@ -18,11 +18,13 @@ export declare const matchQuery: (context: StoreContext, execution: QueryExecuti
18
18
  export declare const countQuery: (context: StoreContext, execution: QueryExecution, by: QueryGrouping) => Promise<QueryGroup[]>;
19
19
  /** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
20
20
  export declare const queryMessagePks: (context: StoreContext, execution: QueryExecution) => Promise<number[]>;
21
+ /** How many messages match, counting no further than `cap + 1`; `undefined` as `matchingPks` says. */
22
+ export declare const countMatches: (context: StoreContext, execution: QueryExecution, cap: number) => number | undefined;
21
23
  /**
22
- * How many messages match, counting no further than `cap + 1`: one SQL count, unranked. `undefined` when a leaf is
23
- * checked row by row (a preset, a pattern), where a count would cost as much as the search itself.
24
+ * The keys of the messages that match, no more than `cap + 1`: one SQL query, unranked. `undefined` when a leaf is
25
+ * checked row by row (a preset, a pattern), where it would cost as much as the search itself.
24
26
  */
25
- export declare const countMatches: (context: StoreContext, execution: QueryExecution, cap: number) => number | undefined;
27
+ export declare const matchingPks: (context: StoreContext, execution: QueryExecution, cap: number) => number[] | undefined;
26
28
  interface QuerySelection {
27
29
  sql: string;
28
30
  params: SqlValue[];
@@ -48,22 +48,24 @@ export const matchQuery = (context, execution) => runQuery(context, execution);
48
48
  export const countQuery = (context, execution, by) => runQuery(context, execution, by);
49
49
  /** All matching message keys, without materializing message bodies or imposing a hit-page limit. */
50
50
  export const queryMessagePks = (context, execution) => runQuery(context, execution, "pks");
51
+ /** How many messages match, counting no further than `cap + 1`; `undefined` as `matchingPks` says. */
52
+ export const countMatches = (context, execution, cap) => matchingPks(context, execution, cap)?.length;
51
53
  /**
52
- * How many messages match, counting no further than `cap + 1`: one SQL count, unranked. `undefined` when a leaf is
53
- * checked row by row (a preset, a pattern), where a count would cost as much as the search itself.
54
+ * The keys of the messages that match, no more than `cap + 1`: one SQL query, unranked. `undefined` when a leaf is
55
+ * checked row by row (a preset, a pattern), where it would cost as much as the search itself.
54
56
  */
55
- export const countMatches = (context, execution, cap) => {
57
+ export const matchingPks = (context, execution, cap) => {
56
58
  const { database, check, where, from, withRanking, ranked, leaves } = compileQuery(context, {
57
59
  ...execution,
58
60
  newest: true,
59
61
  });
60
62
  if (leaves.some(({ test }) => test))
61
63
  return undefined;
62
- const row = database
63
- .prepare(`SELECT count(*) AS n FROM (${withRanking}SELECT m.id ${from} WHERE ${where.sql} LIMIT ?)`)
64
- .get(...ranked.params, ...where.params, cap + 1);
64
+ const rows = database
65
+ .prepare(`${withRanking}SELECT m.id ${from} WHERE ${where.sql} LIMIT ?`)
66
+ .all(...ranked.params, ...where.params, cap + 1);
65
67
  check();
66
- return Number(row?.n ?? 0);
68
+ return rows.map(({ id }) => Number(id));
67
69
  };
68
70
  const compileQuery = (context, execution, boundedAttachments = false) => {
69
71
  const { database } = context;
@@ -100,6 +100,14 @@ export declare const evidenceOf: (context: StoreContext, from: {
100
100
  }) => string[];
101
101
  /** What a memory's or fact's subject is called, for a reader who should not have to look the reference up. */
102
102
  export declare const subjectOf: (database: StoreContext["database"], row: Row) => Pick<Memory, "subject" | "subjectName" | "subjectIsOwner">;
103
+ type Numbered = "memories" | "facts";
104
+ /**
105
+ * The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
106
+ * so without this a forgotten record's number would name the next one, and a stale `memory:N` act on it.
107
+ */
108
+ export declare const nextId: (database: StoreContext["database"], table: Numbered) => number;
109
+ /** Before a delete: remembers the highest id handed out, which the delete may be about to free. */
110
+ export declare const keepHighestId: (database: StoreContext["database"], table: Numbered, at: number) => void;
103
111
  /** Counts every link from or to a row and drops the ones its delete trigger does not: those pointing at it. */
104
112
  export declare const dropLinksOf: (database: StoreContext["database"], thing: {
105
113
  type: string;
@@ -64,6 +64,23 @@ export const subjectOf = (database, row) => {
64
64
  subjectIsOwner: Number(named?.owner) === 1,
65
65
  };
66
66
  };
67
+ const highestKey = (table) => `ids.${table}.highest`;
68
+ /**
69
+ * The id the next row takes: above every row there is and every one forgotten. SQLite hands out the highest id + 1,
70
+ * so without this a forgotten record's number would name the next one, and a stale `memory:N` act on it.
71
+ */
72
+ export const nextId = (database, table) => {
73
+ const kept = database.prepare("SELECT value FROM store_settings WHERE key = ?").get(highestKey(table));
74
+ const highest = database.prepare(`SELECT max(id) AS id FROM ${table}`).get();
75
+ return Math.max(Number(kept?.value ?? 0), Number(highest?.id ?? 0)) + 1;
76
+ };
77
+ /** Before a delete: remembers the highest id handed out, which the delete may be about to free. */
78
+ export const keepHighestId = (database, table, at) => {
79
+ const highest = nextId(database, table) - 1;
80
+ database
81
+ .prepare("INSERT OR REPLACE INTO store_settings (key, value, at) VALUES (?, ?, ?)")
82
+ .run(highestKey(table), String(highest), at);
83
+ };
67
84
  /** Counts every link from or to a row and drops the ones its delete trigger does not: those pointing at it. */
68
85
  export const dropLinksOf = (database, thing) => {
69
86
  const count = Number(database
@@ -153,9 +170,9 @@ export const memoriesStoreOver = (context) => {
153
170
  const replaced = input.supersedes === undefined ? undefined : Number(rowOf(input.supersedes).id);
154
171
  const at = now();
155
172
  const made = Number(database
156
- .prepare("INSERT INTO memories (kind, body, subject_type, subject_id, author_type, author_id, model, confidence, status, " +
157
- "last_verified_at, supersedes_id, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
158
- .get(kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, at, at)?.id);
173
+ .prepare("INSERT INTO memories (id, kind, body, subject_type, subject_id, author_type, author_id, model, confidence, status, " +
174
+ "last_verified_at, supersedes_id, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
175
+ .get(nextId(database, "memories"), kind, body, subject?.type ?? null, subject?.id ?? null, actor.type, actor.id, model, input.confidence ?? null, status, status === "confirmed" ? at : null, replaced ?? null, scope, at, at)?.id);
159
176
  linkEvidence(context, { type: "memory", id: made }, input.evidence, input.author === "owner" ? "owner" : "agent");
160
177
  if (replaced !== undefined)
161
178
  database.prepare("UPDATE memories SET status = 'superseded', updated_at = ? WHERE id = ?").run(at, replaced);
@@ -178,6 +195,7 @@ export const memoriesStoreOver = (context) => {
178
195
  remove: async (reference) => {
179
196
  const id = Number(rowOf(reference).id);
180
197
  const links = erase(database, () => {
198
+ keepHighestId(database, "memories", now());
181
199
  database.prepare("UPDATE memories SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
182
200
  const count = dropLinksOf(database, { type: "memory", id });
183
201
  database.prepare("DELETE FROM memories WHERE id = ?").run(id);
@@ -39,6 +39,12 @@ type MessageFilter = {
39
39
  since?: number;
40
40
  before?: number;
41
41
  };
42
+ /** Each of the account's messages among `pks` that has a vector of `model`, scored against `query`, best first. */
43
+ export declare const scoreMessages: ({ orm }: StoreContext, accountPk: number, { model, query, pks }: {
44
+ model: string;
45
+ query: Float32Array;
46
+ pks: number[];
47
+ }) => NearestMessage[];
42
48
  /** The account's live messages nearest to `query` by their vector of `model`, best first. */
43
49
  export declare const nearestMessages: (context: StoreContext, accountPk: number, { limit, query, ...filter }: MessageFilter & {
44
50
  limit: number;
@@ -59,6 +59,16 @@ const messageRows = ({ orm }, accountPk, { chatKey, model, since, before }, rows
59
59
  ${"ids" in rows
60
60
  ? sql `AND k.id IN (SELECT value FROM json_each(${JSON.stringify(rows.ids)})) ORDER BY k.id`
61
61
  : sql `AND k.id > ${rows.after} ORDER BY k.id LIMIT ${SCAN_PAGE}`}`);
62
+ /** Each of the account's messages among `pks` that has a vector of `model`, scored against `query`, best first. */
63
+ export const scoreMessages = ({ orm }, accountPk, { model, query, pks }) => orm
64
+ .all(sql `SELECT ch.external_id AS chatId, m.external_id AS messageId, v.vector
65
+ FROM chunks k CROSS JOIN messages m ON m.id = k.chunkable_id AND m.deleted_at IS NULL
66
+ JOIN chats ch ON ch.id = m.chat_id AND ch.searchable = 1
67
+ JOIN embeddings v ON v.model = ${model} AND v.content_hash = k.content_hash
68
+ WHERE k.chunkable_type = 'message' AND k.position = 0 AND k.account_id = ${accountPk}
69
+ AND k.chunkable_id IN (SELECT value FROM json_each(${JSON.stringify(pks)}))`)
70
+ .map(({ chatId, messageId, vector }) => ({ chatId, messageId, score: dot(query, vector) }))
71
+ .sort((a, b) => b.score - a.score);
62
72
  /** The account's live messages nearest to `query` by their vector of `model`, best first. */
63
73
  export const nearestMessages = (context, accountPk, { limit, query, ...filter }) => {
64
74
  const score = (row) => ({
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { fold } from "../../normalize.js";
3
+ import { nextId } from "../memories.js";
3
4
  import { thingOf } from "../things.js";
4
5
  export const restoreAnnotationRecords = (database, projectId, snapshot, bindings) => {
5
6
  const mappings = { ...bindings };
@@ -24,8 +25,8 @@ export const restoreAnnotationRecords = (database, projectId, snapshot, bindings
24
25
  if (!author)
25
26
  throw new CliError("validation_error", "project annotation author is required");
26
27
  const row = database
27
- .prepare("INSERT INTO facts(type,statement,status,project_id,subject_type,subject_id,occurred_at,supersedes_id,author_type,author_id,model,confirmed_by_type,confirmed_by_id,scope,source,created_at,updated_at) VALUES(?,?,?,?,NULL,NULL,?,NULL,?,?,?,?,?,?,?,?,?) RETURNING id")
28
- .get(fact.type, fact.statement, fact.status, projectId, fact.occurred_at, author.type, author.id, fact.model, confirmed?.type ?? null, confirmed?.id ?? null, fact.scope, fact.source, fact.created_at, fact.updated_at);
28
+ .prepare("INSERT INTO facts(id,type,statement,status,project_id,subject_type,subject_id,occurred_at,supersedes_id,author_type,author_id,model,confirmed_by_type,confirmed_by_id,scope,source,created_at,updated_at) VALUES(?,?,?,?,?,NULL,NULL,?,NULL,?,?,?,?,?,?,?,?,?) RETURNING id")
29
+ .get(nextId(database, "facts"), fact.type, fact.statement, fact.status, projectId, fact.occurred_at, author.type, author.id, fact.model, confirmed?.type ?? null, confirmed?.id ?? null, fact.scope, fact.source, fact.created_at, fact.updated_at);
29
30
  if (!row)
30
31
  throw new CliError("configuration_error", "project annotation fact insertion failed");
31
32
  mappings[fact.ref] = `fact:${row.id}`;
@@ -3,7 +3,13 @@ export declare const syncStateOf: ({ orm }: StoreContext, accountKey: number, na
3
3
  value: string;
4
4
  at: string;
5
5
  } | undefined;
6
- export declare const writeState: ({ orm, now }: StoreContext, accountKey: number, name: string, value: string) => void;
6
+ /** A sync state value saved with `MailStore.saveThreads`, by the account's row id. */
7
+ export interface SyncStateSave {
8
+ accountId: number;
9
+ name: string;
10
+ value: string;
11
+ }
12
+ export declare const writeState: ({ orm, now }: Pick<StoreContext, "orm" | "now">, accountKey: number, name: string, value: string) => void;
7
13
  export declare const clearState: ({ orm }: StoreContext, accountKey: number, name: string) => void;
8
14
  /** Taken when free, expired, or already this holder's; another holder's running lease stays. */
9
15
  export declare const claim: ({ orm, now }: StoreContext, chatKey: number, anchor: string, holder: string, forMs: number) => boolean;
@@ -11,7 +11,8 @@ export declare const write: <T>(database: CacheDatabase, body: () => T, { before
11
11
  /**
12
12
  * A write to tables that queue no derived work, in a loop that budgets its lock time or reports what it did: a fill,
13
13
  * a backfill, a rebuild. It drains nothing, so it neither does nor hides another write's work; the queues wait for
14
- * the next `write`.
14
+ * the next `write`. The outermost pauses 2 ms after it commits: a loop that begins again at once leaves another
15
+ * process's `write` a gap of microseconds, and it misses that gap until its 5 s wait runs out.
15
16
  */
16
17
  export declare const writeBatch: <T>(database: CacheDatabase, body: () => T) => T;
17
18
  /** Several queries that must see one state of the file. Never drains: that is the outer write's work, or nobody's. */
@@ -11,7 +11,8 @@ const isBusy = (error) => {
11
11
  /**
12
12
  * Takes the write lock, waiting as long as the connection's `busy_timeout`. SQLite's own wait sleeps 1 ms, then up to
13
13
  * 100 ms between tries, so a process that commits and begins again at once is never seen free and the waiter fails
14
- * with "database is locked" after the full timeout. Trying every millisecond sees the gap between two writes.
14
+ * with "database is locked" after the full timeout. Trying every millisecond sees a gap of a fraction of a
15
+ * millisecond between two writes; a writer that leaves none still starves it, hence `writeBatch`'s pause.
15
16
  */
16
17
  const beginImmediate = (database) => {
17
18
  const timeout = Number(database.prepare("PRAGMA busy_timeout").get()?.timeout ?? 0);
@@ -53,9 +54,17 @@ export const write = (database, body, { beforeCommit } = {}) => transact(databas
53
54
  /**
54
55
  * A write to tables that queue no derived work, in a loop that budgets its lock time or reports what it did: a fill,
55
56
  * a backfill, a rebuild. It drains nothing, so it neither does nor hides another write's work; the queues wait for
56
- * the next `write`.
57
+ * the next `write`. The outermost pauses 2 ms after it commits: a loop that begins again at once leaves another
58
+ * process's `write` a gap of microseconds, and it misses that gap until its 5 s wait runs out.
57
59
  */
58
- export const writeBatch = (database, body) => transact(database, body, false);
60
+ export const writeBatch = (database, body) => {
61
+ const outer = connectionOf(database).depth === 0;
62
+ const result = transact(database, body, false);
63
+ if (outer)
64
+ Atomics.wait(pause, 0, 0, BATCH_PAUSE_MS);
65
+ return result;
66
+ };
67
+ const BATCH_PAUSE_MS = 2;
59
68
  const transact = (database, body, drain, beforeCommit) => {
60
69
  const connection = connectionOf(database);
61
70
  if (connection.reading)
@@ -87,6 +87,19 @@ export interface AccountVectors {
87
87
  messageId: Id;
88
88
  score: number;
89
89
  }[]>;
90
+ /**
91
+ * The account's messages among `pks` (store keys, as a query's matches give them) scored against `query` by their
92
+ * own vector of `model`, best first: a filter's own messages ranked, not the nearest overall.
93
+ */
94
+ scoreMessages(key: AccountKey, options: {
95
+ model: string;
96
+ query: Float32Array;
97
+ pks: number[];
98
+ }): Promise<{
99
+ chatId: Id;
100
+ messageId: Id;
101
+ score: number;
102
+ }[]>;
90
103
  /** The current build's distinct chunks, and how many have a vector of `model`. */
91
104
  vectorStatus(key: AccountKey, chatId: Id, model: string): Promise<{
92
105
  chunks: number;
@@ -147,6 +147,10 @@ export const accountVectorsOver = (context, mail) => {
147
147
  query,
148
148
  });
149
149
  },
150
+ scoreMessages: async (key, options) => {
151
+ const accountPk = findAccountPk(key);
152
+ return accountPk === undefined ? [] : messageChunks.scoreMessages(context, accountPk, options);
153
+ },
150
154
  vectorStatus: async (key, chatId, model) => {
151
155
  const chatKey = chatKeyOf(key, chatId);
152
156
  return chatKey === undefined ? { chunks: 0, embedded: 0 } : vectors.vectorStatus(context, chatKey, model);
@@ -2,6 +2,7 @@ import { StringDecoder } from "node:string_decoder";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { Option } from "commander";
4
4
  import { MAX_SECRET_INPUT } from "../cli/input-policy.js";
5
+ import { stepLines } from "./step-lines.js";
5
6
  /**
6
7
  * Opt-in, because a read nobody answers hangs: an agent running `session start` in a shell must get the refusal it
7
8
  * gets today, and only a caller that holds stdin open and answers each `needs` line (WireCat Desktop) passes it.
@@ -10,10 +11,7 @@ export const answerStdinOption = () => new Option("--answer-stdin", "with --json
10
11
  export const signInSteps = ({ diagnostic, json, answers, input = process.stdin }) => {
11
12
  if (answers && !json)
12
13
  throw new CliError("validation_error", "--answer-stdin needs --json");
13
- const emit = (event) => {
14
- if (json)
15
- diagnostic(JSON.stringify(event));
16
- };
14
+ const emit = stepLines(diagnostic, json);
17
15
  const next = answers ? lineReader(input) : undefined;
18
16
  return {
19
17
  json,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Machine-readable step lines: one JSON object per line on the diagnostic stream, only in `--json` / `--jsonl`, so
3
+ * stdout keeps its single answer and a person at a terminal sees nothing new. Sign-in and `messages sync` share it.
4
+ */
5
+ export declare const stepLines: (diagnostic: (line: string) => void, json: boolean) => (event: Record<string, unknown> & {
6
+ event: string;
7
+ }) => void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Machine-readable step lines: one JSON object per line on the diagnostic stream, only in `--json` / `--jsonl`, so
3
+ * stdout keeps its single answer and a person at a terminal sees nothing new. Sign-in and `messages sync` share it.
4
+ */
5
+ export const stepLines = (diagnostic, json) => (event) => {
6
+ if (json)
7
+ diagnostic(JSON.stringify(event));
8
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.505.0",
3
+ "version": "0.507.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",
@@ -247,7 +247,7 @@
247
247
  "@types/node": "^22.20.5",
248
248
  "@types/pngjs": "6.0.5",
249
249
  "@vitest/coverage-v8": "^5.0.5",
250
- "@wirecat/cli-base": "0.67.0",
250
+ "@wirecat/cli-base": "0.68.0",
251
251
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
252
252
  "commander": "15.0.0",
253
253
  "cspell": "10.4.0",
@@ -267,7 +267,7 @@
267
267
  },
268
268
  "peerDependencies": {
269
269
  "@napi-rs/canvas": ">=0.1.69 <2",
270
- "@wirecat/cli-base": ">=0.67.0 <0.68.0",
270
+ "@wirecat/cli-base": ">=0.68.0 <0.69.0",
271
271
  "commander": ">=15"
272
272
  },
273
273
  "bin": {