@wirecat/cli-core 0.394.0 → 0.396.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 (80) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/cli/commands-command.js +6 -0
  3. package/dist/cli/config-command.js +5 -2
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/index.js +1 -1
  6. package/dist/cli/input-fields.d.ts +3 -0
  7. package/dist/cli/input-fields.js +27 -0
  8. package/dist/cli/messenger/account-command.js +8 -3
  9. package/dist/cli/messenger/account-sessions-command.js +7 -2
  10. package/dist/cli/messenger/attachments-command.js +16 -4
  11. package/dist/cli/messenger/conversation-answers.d.ts +5 -0
  12. package/dist/cli/messenger/conversation-examples.d.ts +2 -0
  13. package/dist/cli/messenger/conversation-examples.js +25 -0
  14. package/dist/cli/messenger/conversations-command.js +2 -0
  15. package/dist/cli/messenger/mcp-command.js +12 -3
  16. package/dist/cli/messenger/message-answers.d.ts +5 -0
  17. package/dist/cli/messenger/message-writes.d.ts +5 -0
  18. package/dist/cli/messenger/models-command.js +15 -6
  19. package/dist/cli/messenger/polls-command.js +20 -5
  20. package/dist/cli/messenger/records-command.js +10 -2
  21. package/dist/cli/messenger/search-answers.js +2 -0
  22. package/dist/cli/messenger/search-command.js +12 -0
  23. package/dist/cli/messenger/searches-command.d.ts +2 -0
  24. package/dist/cli/messenger/searches-command.js +2 -1
  25. package/dist/cli/messenger/server-command.js +19 -7
  26. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  27. package/dist/cli/messenger/tasks-command.js +18 -6
  28. package/dist/cli/program.js +5 -2
  29. package/dist/cli/runs/command.js +11 -4
  30. package/dist/cli/skill-command.js +6 -1
  31. package/dist/entities/evidence.d.ts +40 -0
  32. package/dist/entities/index.d.ts +72 -2
  33. package/dist/entities/index.js +2 -0
  34. package/dist/entities/message.d.ts +9 -0
  35. package/dist/entities/message.js +6 -0
  36. package/dist/entities/saved-search.d.ts +1 -1
  37. package/dist/entities/saved-search.js +1 -1
  38. package/dist/errors/reasons.d.ts +1 -0
  39. package/dist/errors/reasons.js +16 -0
  40. package/dist/meetings/cli/search.js +1 -1
  41. package/dist/meetings/hit-query.d.ts +10 -0
  42. package/dist/meetings/hit-query.js +85 -1
  43. package/dist/meetings/index.d.ts +1 -1
  44. package/dist/meetings/index.js +1 -1
  45. package/dist/meetings/mcp/index.d.ts +15 -15
  46. package/dist/search/lucene/registry.js +2 -2
  47. package/dist/services/index.d.ts +3 -2
  48. package/dist/services/index.js +2 -1
  49. package/dist/services/meeting-combined.d.ts +1 -1
  50. package/dist/services/meeting-combined.js +4 -3
  51. package/dist/services/meeting-hit-search.d.ts +1 -1
  52. package/dist/services/meeting-hit-search.js +4 -2
  53. package/dist/services/meeting-searches.d.ts +25 -0
  54. package/dist/services/meeting-searches.js +35 -0
  55. package/dist/services/messages-search.js +13 -2
  56. package/dist/services/messages.d.ts +5 -0
  57. package/dist/services/search-record.d.ts +1 -1
  58. package/dist/services/searches.d.ts +1 -0
  59. package/dist/services/searches.js +17 -4
  60. package/dist/store/search-index.d.ts +4 -1
  61. package/dist/store/search-index.js +10 -1
  62. package/dist/store/searches.d.ts +5 -4
  63. package/dist/store/searches.js +4 -4
  64. package/dist/store/sqlite/hit-places.d.ts +12 -0
  65. package/dist/store/sqlite/hit-places.js +76 -0
  66. package/dist/store/sqlite/lucene-fields.d.ts +8 -0
  67. package/dist/store/sqlite/lucene-fields.js +6 -4
  68. package/dist/store/sqlite/lucene.js +25 -70
  69. package/dist/store/sqlite/manifest.js +2 -0
  70. package/dist/store/sqlite/migrations.generated.js +19 -0
  71. package/dist/store/sqlite/note-index.d.ts +11 -1
  72. package/dist/store/sqlite/note-index.js +18 -6
  73. package/dist/store/sqlite/schema.d.ts +39 -0
  74. package/dist/store/sqlite/schema.js +4 -0
  75. package/dist/store/sqlite/searches.d.ts +4 -4
  76. package/dist/store/sqlite/searches.js +21 -6
  77. package/dist/store/sqlite/transcript-query.d.ts +40 -0
  78. package/dist/store/sqlite/transcript-query.js +108 -0
  79. package/dist/store/sqlite/words.d.ts +6 -0
  80. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { CORPORA, corpusIndexState, drainCorpus } from "./sqlite/note-index.js";
1
2
  import * as searchIndex from "./sqlite/search-index.js";
2
3
  import * as stems from "./sqlite/stems.js";
3
4
  import { fillStems } from "./sqlite/stems-fill.js";
@@ -5,13 +6,21 @@ import { write } from "./sqlite/transaction.js";
5
6
  import * as words from "./sqlite/words.js";
6
7
  export const searchIndexStoreOver = (context) => {
7
8
  const { database } = context;
9
+ const stemmerFor = stems.stemmerCache();
8
10
  return {
9
11
  knownTerms: async (terms) => words.knownTerms(context, terms),
10
12
  termCandidates: async (trigrams, lengths) => words.termCandidates(context, trigrams, lengths),
11
13
  searchIndexState: async () => searchIndex.searchIndexState(database),
12
14
  fillSearchIndex: async ({ until } = {}) => searchIndex.fillSearchIndex(database, { now: context.now, ...(until ? { until } : {}) }),
13
15
  stemsState: async () => stems.stemsState(database),
14
- fillStems: async ({ until } = {}) => fillStems(database, { now: context.now, ...(until ? { until } : {}) }),
16
+ fillStems: async ({ until } = {}) => {
17
+ const filled = fillStems(database, { now: context.now, ...(until ? { until } : {}) });
18
+ // Read first: every search asks, and an idle drain must not take the write lock.
19
+ const transcripts = corpusIndexState(database, CORPORA.transcript);
20
+ if (transcripts.wanted !== null && (transcripts.pending > 0 || transcripts.built !== transcripts.wanted))
21
+ drainCorpus(database, CORPORA.transcript, stemmerFor, until ? { until } : {});
22
+ return filled;
23
+ },
15
24
  stemmers: async () => stems.savedStemmers(database),
16
25
  saveStemmers: async (stemmers) => write(database, () => stems.saveStoreStemmers(database, stemmers, context.now())),
17
26
  };
@@ -1,5 +1,5 @@
1
1
  import type { StoreContext } from "./sqlite/open.js";
2
- import type { SearchRecord, StoredSearch } from "./sqlite/searches.js";
2
+ import type { SearchCommand, SearchRecord, StoredSearch } from "./sqlite/searches.js";
3
3
  export interface SearchesStore {
4
4
  /** One run of a search or stats; the same unnamed run again counts on its row. `saved` is the saved search it ran. */
5
5
  recordSearch(run: SearchRecord, options?: {
@@ -11,11 +11,12 @@ export interface SearchesStore {
11
11
  }): Promise<StoredSearch>;
12
12
  /** A saved search by name, or any row by id. */
13
13
  storedSearch(reference: string): Promise<StoredSearch | undefined>;
14
- savedSearches(): Promise<StoredSearch[]>;
14
+ /** `commands`: only rows of these kinds; the table is shared by every CLI on the store. */
15
+ savedSearches(commands?: readonly SearchCommand[]): Promise<StoredSearch[]>;
15
16
  /** Runs, newest first, one more than `limit` when there are more. */
16
- searchHistory(limit: number, offset?: number): Promise<StoredSearch[]>;
17
+ searchHistory(limit: number, offset?: number, commands?: readonly SearchCommand[]): Promise<StoredSearch[]>;
17
18
  deleteSearch(reference: string): Promise<StoredSearch>;
18
19
  /** Drops the unnamed runs; answers how many. */
19
- clearSearchHistory(): Promise<number>;
20
+ clearSearchHistory(commands?: readonly SearchCommand[]): Promise<number>;
20
21
  }
21
22
  export declare const searchesStoreOver: (context: StoreContext) => SearchesStore;
@@ -8,15 +8,15 @@ export const searchesStoreOver = (context) => {
8
8
  return write(database, () => searchRecords.saveSearch(context, name, search, replace));
9
9
  },
10
10
  storedSearch: async (reference) => searchRecords.findSearch(context, reference),
11
- savedSearches: async () => searchRecords.savedSearches(context),
12
- searchHistory: async (limit, offset) => searchRecords.searchHistory(context, limit, offset),
11
+ savedSearches: async (commands) => searchRecords.savedSearches(context, commands),
12
+ searchHistory: async (limit, offset, commands) => searchRecords.searchHistory(context, limit, offset, commands),
13
13
  deleteSearch: async (reference) => {
14
14
  return write(database, () => searchRecords.deleteSearch(context, reference));
15
15
  },
16
- clearSearchHistory: async () => {
16
+ clearSearchHistory: async (commands) => {
17
17
  let cleared = 0;
18
18
  write(database, () => {
19
- cleared = searchRecords.clearHistory(context);
19
+ cleared = searchRecords.clearHistory(context, commands);
20
20
  });
21
21
  return cleared;
22
22
  },
@@ -0,0 +1,12 @@
1
+ import type { ResolvedPredicate } from "../../search/lucene/resolved.js";
2
+ import type { CacheDatabase } from "../driver.js";
3
+ import { type Corpus, type Leaf } from "./lucene-fields.js";
4
+ import type { hitsByPk } from "./search.js";
5
+ import { type TranscriptTerms } from "./transcript-query.js";
6
+ type Hits = ReturnType<typeof hitsByPk>;
7
+ /**
8
+ * Which place of each hit holds the words, first match wins in the order of `MatchPlace`; a hit its transcript
9
+ * holds only through a spelling speech recognition garbled is `corrected`.
10
+ */
11
+ export declare const placeHits: (database: CacheDatabase, corpus: Corpus, leaves: Leaf[], transcripts: Map<ResolvedPredicate, TranscriptTerms>, found: Hits, pks: number[]) => Hits;
12
+ export {};
@@ -0,0 +1,76 @@
1
+ import { normalize } from "../normalize.js";
2
+ import { ftsMatch, SPEECH } from "./lucene-fields.js";
3
+ import { spokenTranscripts } from "./transcript-query.js";
4
+ /**
5
+ * Which place of each hit holds the words, first match wins in the order of `MatchPlace`; a hit its transcript
6
+ * holds only through a spelling speech recognition garbled is `corrected`.
7
+ */
8
+ export const placeHits = (database, corpus, leaves, transcripts, found, pks) => {
9
+ const placeLeaves = leaves.filter(({ node }) => (node.operator === "term" || node.operator === "phrase") &&
10
+ (node.implicit || node.field === "content" || node.field === "transcript"));
11
+ if (!placeLeaves.length || !pks.length || found.length !== pks.length)
12
+ return found;
13
+ const keys = JSON.stringify(pks);
14
+ const wordy = placeLeaves.filter(({ node }) => /[\p{L}\p{N}]/u.test(normalize(node.value)));
15
+ if (!wordy.length)
16
+ return found;
17
+ const match = wordy.map(({ node }) => ftsMatch(node)).join(" OR ");
18
+ const implicit = placeLeaves.some(({ node }) => node.implicit);
19
+ const inText = new Set(implicit
20
+ ? database
21
+ .prepare(`SELECT rowid AS id FROM ${corpus.words} WHERE ${corpus.words} MATCH ? AND rowid IN (SELECT value FROM json_each(?))${placeLeaves.some(({ fragment }) => fragment.stems)
22
+ ? ` UNION SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ? AND rowid IN (SELECT value FROM json_each(?))`
23
+ : ""}`)
24
+ .all(`normalized_text : (${match})`, keys, ...(placeLeaves.some(({ fragment }) => fragment.stems)
25
+ ? [
26
+ `stems : (${placeLeaves.flatMap(({ fragment }) => (fragment.stems ? [`(${fragment.stems})`] : [])).join(" OR ")})`,
27
+ keys,
28
+ ]
29
+ : []))
30
+ .map(({ id }) => Number(id))
31
+ : []);
32
+ const files = database
33
+ .prepare(`SELECT att.attachable_id AS id, att.text AS text, ${SPEECH} AS speech FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) ORDER BY att.position`)
34
+ .all(keys, match);
35
+ const fileFields = new Set(placeLeaves.flatMap(({ node }) => (node.implicit ? ["transcript", "content"] : [node.field])));
36
+ const spoken = spokenTranscripts(database, corpus.item, wordy.flatMap(({ node }) => transcripts.get(node) ?? []), keys);
37
+ const allNames = implicit
38
+ ? database
39
+ .prepare(`SELECT att.attachable_id AS id, att.name AS name FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.name IS NOT NULL ORDER BY att.position`)
40
+ .all(keys)
41
+ : [];
42
+ return found.map((hit, index) => {
43
+ const pk = pks[index];
44
+ const transcript = spoken.get(pk);
45
+ const file = files.find((row) => Number(row.id) === pk && Number(row.speech) !== 1);
46
+ const name = allNames.find((row) => {
47
+ if (Number(row.id) !== pk)
48
+ return false;
49
+ const words = normalize(String(row.name)).split(/[^\p{L}\p{N}]+/u);
50
+ return wordy.some(({ node }) => {
51
+ const phrase = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
52
+ if (node.slop !== undefined)
53
+ return phrase.every((word) => words.includes(word));
54
+ return words.some((_, at) => phrase.every((word, offset) => words[at + offset] === word));
55
+ });
56
+ });
57
+ const place = inText.has(pk)
58
+ ? ["text", hit.text]
59
+ : transcript && fileFields.has("transcript")
60
+ ? ["transcript", String(transcript.text)]
61
+ : name
62
+ ? ["filename", String(name.name)]
63
+ : file && fileFields.has("content")
64
+ ? ["file", String(file.text)]
65
+ : undefined;
66
+ if (!place)
67
+ return hit;
68
+ const fixes = place[0] === "transcript" ? (transcript?.corrections ?? []) : [];
69
+ return {
70
+ ...hit,
71
+ matchedIn: place[0],
72
+ matchedText: place[1],
73
+ ...(fixes.length ? { match: "corrected", corrections: fixes } : {}),
74
+ };
75
+ });
76
+ };
@@ -1,5 +1,6 @@
1
1
  import { type Automaton, type MatchBudget } from "../../search/lucene/automaton.js";
2
2
  import type { QueryExecution, ResolvedPredicate } from "../../search/lucene/resolved.js";
3
+ import { type Stemmer } from "../../search/stem.js";
3
4
  import type { CacheDatabase, SqlValue } from "../driver.js";
4
5
  export interface Fragment {
5
6
  sql: string;
@@ -57,6 +58,11 @@ export interface FieldScope {
57
58
  sql: string;
58
59
  params: SqlValue[];
59
60
  };
61
+ /** The items whose voice-note transcripts a leaf matches: typed, in another form, or garbled. */
62
+ transcripts: (node: ResolvedPredicate) => {
63
+ sql: string;
64
+ params: SqlValue[];
65
+ };
60
66
  everywhere: (node: ResolvedPredicate, fragment: Fragment) => Fragment;
61
67
  /** Counts the patterns' expanded words against the query's budget. */
62
68
  expand: (count: number) => number;
@@ -67,6 +73,8 @@ type Compiled = {
67
73
  test?: Leaf["test"];
68
74
  } | Leaf;
69
75
  type FieldCompiler = (node: ResolvedPredicate, scope: FieldScope) => Compiled;
76
+ /** A leaf as an FTS5 match over a stem index: phrases of stems, or with `~N` their `NEAR`. */
77
+ export declare const stemMatch: (stemmer: Stemmer, node: ResolvedPredicate) => string | undefined;
70
78
  /** One compiler per field of `QUERY_FIELDS`, by its name: a leaf compiles by a lookup, never a branch. */
71
79
  export declare const FIELD_COMPILERS: Readonly<Record<string, FieldCompiler>>;
72
80
  export {};
@@ -72,10 +72,12 @@ const mentioned = ({ mail }, where, params) => mail
72
72
  : bound(`m.id IN (SELECT mn.source_id FROM mentions mn WHERE mn.source_type='message' AND ${where} ` +
73
73
  "UNION SELECT a.attachable_id FROM mentions mn JOIN attachments a ON a.id=mn.source_id " +
74
74
  `AND a.attachable_type='message' WHERE mn.source_type='attachment' AND ${where})`, ...params, ...params);
75
- const stemsOf = ({ execution: { stemmer } }, node) => {
75
+ const stemsOf = ({ execution: { stemmer } }, node) => stemmer ? stemMatch(stemmer, node) : undefined;
76
+ /** A leaf as an FTS5 match over a stem index: phrases of stems, or with `~N` their `NEAR`. */
77
+ export const stemMatch = (stemmer, node) => {
76
78
  if (node.slop === undefined)
77
- return stemmer?.phrases(node.value);
78
- const sequences = (stemmer?.stemSequences(node.value) ?? []).filter((sequence) => sequence.length);
79
+ return stemmer.phrases(node.value);
80
+ const sequences = stemmer.stemSequences(node.value).filter((sequence) => sequence.length);
79
81
  return sequences.length
80
82
  ? sequences
81
83
  .map((sequence) => (sequence.length > 1 ? nearMatch(sequence, node.slop) : quoted(sequence.join(""))))
@@ -256,7 +258,7 @@ const size = (node, { corpus }) => {
256
258
  const attachmentText = (which) => (node, scope) => {
257
259
  const text = normalize(node.value);
258
260
  // Never `fts`: the ranking ANDs every required word into message_words, where a file's words are not.
259
- const set = scope.attachmentWords(ftsMatch(node), which);
261
+ const set = which === "transcript" ? scope.transcripts(node) : scope.attachmentWords(ftsMatch(node), which);
260
262
  return always(/[\p{L}\p{N}]/u.test(text) ? bound(`m.id IN (${set.sql})`, ...set.params) : bound("0"));
261
263
  };
262
264
  const date = (node) => {
@@ -2,11 +2,12 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { compileAutomaton } from "../../search/lucene/automaton.js";
3
3
  import { isStemmed } from "../../search/lucene/resolved.js";
4
4
  import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
5
- import { normalize } from "../normalize.js";
6
5
  import { emailHitsByPk } from "./emails.js";
6
+ import { placeHits } from "./hit-places.js";
7
7
  import { combine, FIELD_COMPILERS, ftsMatch, SPEECH, } from "./lucene-fields.js";
8
8
  import { hitsByPk } from "./search.js";
9
9
  import { read } from "./transaction.js";
10
+ import { transcriptItems, transcriptTerms } from "./transcript-query.js";
10
11
  export { prefixOf } from "./lucene-fields.js";
11
12
  const MESSAGES = {
12
13
  rows: "messages",
@@ -82,13 +83,19 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
82
83
  sql: `SELECT att.attachable_id FROM attachments att WHERE att.attachable_type='${corpus.item}'${which === "any" ? "" : ` AND ${which === "file" ? "NOT " : ""}${SPEECH}`} AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?)`,
83
84
  params: [match],
84
85
  });
86
+ const transcripts = transcriptTerms(database, execution.root, corpus.item);
87
+ const said = (node, part) => transcriptItems(corpus.item, transcripts.get(node) ?? { match: ftsMatch(node), widened: [], from: node.value }, part);
85
88
  /** A bare word also matches a transcript, a file's text and a word of a file's name. */
86
89
  const everywhere = (node, fragment) => {
87
90
  if (!node.implicit || fragment.sql === "0")
88
91
  return fragment;
89
92
  const match = ftsMatch(node);
90
- const files = attachmentWords(match, "any");
91
- const elsewhere = { sql: `${files.sql} UNION ${namedSql}`, params: [...files.params, match] };
93
+ const files = attachmentWords(match, "file");
94
+ const spoken = said(node);
95
+ const elsewhere = {
96
+ sql: `${files.sql} UNION ${spoken.sql} UNION ${namedSql}`,
97
+ params: [...files.params, ...spoken.params, match],
98
+ };
92
99
  return {
93
100
  ...fragment,
94
101
  sql: `(${fragment.sql} OR m.id IN (${elsewhere.sql}))`,
@@ -107,6 +114,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
107
114
  patternOf,
108
115
  expanded,
109
116
  attachmentWords,
117
+ transcripts: (node) => said(node),
110
118
  everywhere,
111
119
  expand: (count) => {
112
120
  expansions += count;
@@ -284,14 +292,21 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
284
292
  ranked.sql.push(`f(id, rank) AS MATERIALIZED (SELECT rowid, bm25(${rankIndex.table}, 1.0, 0.0) FROM ${rankIndex.table} WHERE ${rankIndex.table} MATCH ?)`);
285
293
  ranked.params.push(`${rankIndex.column} : (${ranking})`);
286
294
  }
287
- // Among hits the message text does not hold: a transcript, then a file's name, then a file's text. bm25 scores
288
- // of different indexes do not compare, and a long spreadsheet must not crowd out what was said.
289
- const places = [...fragments.values()].flatMap(({ node, fragment }) => fragment.elsewhere && fragment.fts ? [ftsMatch(node)] : []);
295
+ // Among hits the message text does not hold: a transcript as typed, in another form, then a file's name, a file's
296
+ // text, and last a garbled spelling. bm25 scores of different indexes do not compare, and a long spreadsheet must
297
+ // not crowd out what was said.
298
+ const placeNodes = [...fragments.values()].flatMap(({ node, fragment }) => fragment.elsewhere && fragment.fts ? [node] : []);
299
+ const places = placeNodes.map(ftsMatch);
290
300
  if (places.length && !execution.newest) {
291
- ranked.sql.push(`s(id, tier) AS MATERIALIZED (SELECT att.attachable_id, min(CASE WHEN ${SPEECH} THEN 1 ELSE 3 END) FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) GROUP BY att.attachable_id UNION ALL SELECT attachable_id, 2 FROM (${namedSql}))`);
292
- ranked.params.push(places.join(" OR "), places.join(" OR "));
301
+ const spoken = (part, tier) => placeNodes.map((node) => {
302
+ const one = said(node, part);
303
+ return { sql: `SELECT attachable_id, ${tier} FROM (${one.sql})`, params: one.params };
304
+ });
305
+ const parts = [...spoken("forms", 2), ...spoken("widened", 5)];
306
+ ranked.sql.push(`s(id, tier) AS MATERIALIZED (SELECT att.attachable_id, min(CASE WHEN ${SPEECH} THEN 1 ELSE 4 END) FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) GROUP BY att.attachable_id UNION ALL SELECT attachable_id, 3 FROM (${namedSql})${parts.map(({ sql }) => ` UNION ALL ${sql}`).join("")})`);
307
+ ranked.params.push(places.join(" OR "), places.join(" OR "), ...parts.flatMap(({ params }) => params));
293
308
  }
294
- const placeTier = places.length && !execution.newest ? "coalesce((SELECT min(tier) FROM s WHERE s.id=m.id), 4)," : "";
309
+ const placeTier = places.length && !execution.newest ? "coalesce((SELECT min(tier) FROM s WHERE s.id=m.id), 6)," : "";
295
310
  if (exactTier) {
296
311
  ranked.sql.push(`x(id) AS MATERIALIZED (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)`);
297
312
  ranked.params.push(`normalized_text : (${exactTier})`);
@@ -336,67 +351,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
336
351
  count: Number(row.count),
337
352
  }));
338
353
  };
339
- const placeLeaves = [...fragments.values()].filter(({ node }) => (node.operator === "term" || node.operator === "phrase") &&
340
- (node.implicit || node.field === "content" || node.field === "transcript"));
341
- /** Which place of each hit holds the words, first match wins in the order of `MatchPlace`. */
342
- const placed = (found, pks) => {
343
- if (!placeLeaves.length || !pks.length || found.length !== pks.length)
344
- return found;
345
- const keys = JSON.stringify(pks);
346
- const wordy = placeLeaves.filter(({ node }) => /[\p{L}\p{N}]/u.test(normalize(node.value)));
347
- if (!wordy.length)
348
- return found;
349
- const match = wordy.map(({ node }) => ftsMatch(node)).join(" OR ");
350
- const implicit = placeLeaves.some(({ node }) => node.implicit);
351
- const inText = new Set(implicit
352
- ? database
353
- .prepare(`SELECT rowid AS id FROM ${corpus.words} WHERE ${corpus.words} MATCH ? AND rowid IN (SELECT value FROM json_each(?))${placeLeaves.some(({ fragment }) => fragment.stems)
354
- ? ` UNION SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ? AND rowid IN (SELECT value FROM json_each(?))`
355
- : ""}`)
356
- .all(`normalized_text : (${match})`, keys, ...(placeLeaves.some(({ fragment }) => fragment.stems)
357
- ? [
358
- `stems : (${placeLeaves.flatMap(({ fragment }) => (fragment.stems ? [`(${fragment.stems})`] : [])).join(" OR ")})`,
359
- keys,
360
- ]
361
- : []))
362
- .map(({ id }) => Number(id))
363
- : []);
364
- const files = database
365
- .prepare(`SELECT att.attachable_id AS id, att.text AS text, ${SPEECH} AS speech FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) ORDER BY att.position`)
366
- .all(keys, match);
367
- const fileFields = new Set(placeLeaves.flatMap(({ node }) => (node.implicit ? ["transcript", "content"] : [node.field])));
368
- const allNames = implicit
369
- ? database
370
- .prepare(`SELECT att.attachable_id AS id, att.name AS name FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.name IS NOT NULL ORDER BY att.position`)
371
- .all(keys)
372
- : [];
373
- return found.map((hit, index) => {
374
- const pk = pks[index];
375
- const transcript = files.find((row) => Number(row.id) === pk && Number(row.speech) === 1);
376
- const file = files.find((row) => Number(row.id) === pk && Number(row.speech) !== 1);
377
- const name = allNames.find((row) => {
378
- if (Number(row.id) !== pk)
379
- return false;
380
- const words = normalize(String(row.name)).split(/[^\p{L}\p{N}]+/u);
381
- return wordy.some(({ node }) => {
382
- const phrase = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
383
- if (node.slop !== undefined)
384
- return phrase.every((word) => words.includes(word));
385
- return words.some((_, at) => phrase.every((word, offset) => words[at + offset] === word));
386
- });
387
- });
388
- const place = inText.has(pk)
389
- ? ["text", hit.text]
390
- : transcript && fileFields.has("transcript")
391
- ? ["transcript", String(transcript.text)]
392
- : name
393
- ? ["filename", String(name.name)]
394
- : file && fileFields.has("content")
395
- ? ["file", String(file.text)]
396
- : undefined;
397
- return place ? { ...hit, matchedIn: place[0], matchedText: place[1] } : hit;
398
- });
399
- };
354
+ const placed = (found, pks) => placeHits(database, corpus, [...fragments.values()], transcripts, found, pks);
400
355
  const hits = (pks) => placed(mail ? emailHitsByPk(database, pks) : hitsByPk(context, pks), pks);
401
356
  return {
402
357
  corpus,
@@ -34,6 +34,8 @@ export const MANIFEST = [
34
34
  { name: "20261021214318_version-26-message-reactions", version: 26, minCompatible: 1 },
35
35
  { name: "20261021221808_version-27-message-delivery", version: 27, minCompatible: 1 },
36
36
  { name: "20261021224625_version-28-chat-state", version: 28, minCompatible: 1 },
37
+ { name: "20261023105057_version-29-transcript-words", version: 29, minCompatible: 1 },
38
+ { name: "20261023105101_version-29-transcript-words-triggers", version: 29, minCompatible: 1 },
37
39
  ];
38
40
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
39
41
  const migrations = [];
@@ -577,5 +577,24 @@ export const GENERATED = [
577
577
  "ALTER TABLE `chats` ADD `marked_unread` integer;",
578
578
  "ALTER TABLE `chats` ADD `unread_counted_at` integer;"
579
579
  ]
580
+ },
581
+ {
582
+ "name": "20261023105057_version-29-transcript-words",
583
+ "statements": [
584
+ "CREATE TABLE `transcript_index_pending` (\n\t`id` integer NOT NULL,\n\t`indexable_type` text NOT NULL,\n\tCONSTRAINT `transcript_index_pending_pk` PRIMARY KEY(`indexable_type`, `id`)\n);"
585
+ ]
586
+ },
587
+ {
588
+ "name": "20261023105101_version-29-transcript-words-triggers",
589
+ "statements": [
590
+ "-- Words and stems of a voice note's transcript, rowid = attachment id. The words are the vocabulary garbled\n-- spellings are read from; the typed form is still found in attachment_words. Stemming needs JS, so the\n-- triggers only queue the attachment and the next search indexes it, as tasks do.\nCREATE VIRTUAL TABLE transcript_words USING fts5(\n normalized_text, scope,\n content = '', contentless_delete = 1,\n tokenize = 'unicode61 remove_diacritics 2', prefix = '3');",
591
+ "CREATE VIRTUAL TABLE transcript_words_vocab USING fts5vocab(transcript_words, 'col');",
592
+ "CREATE VIRTUAL TABLE transcript_stems USING fts5(\n stems, scope,\n content = '', contentless_delete = 1,\n tokenize = 'unicode61 remove_diacritics 2');",
593
+ "-- A transcript is the text of a message's voice attachment, whoever wrote it, as `speech()` in lucene-fields.ts.\nCREATE TRIGGER transcript_index_ai AFTER INSERT ON attachments\n WHEN new.attachable_type = 'message' AND (coalesce(new.extractor, '') LIKE 'speech:%'\n OR new.kind IN ('voice', 'video_note') OR coalesce(new.mime, '') = 'audio/ogg') BEGIN\n INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id) VALUES ('attachment', new.id);\nEND;",
594
+ "-- Queued whether it became a transcript or stopped being one: the drain drops what is no longer live.\nCREATE TRIGGER transcript_index_au AFTER UPDATE OF text, kind, mime, extractor, attachable_type ON attachments\n WHEN (old.text IS NOT new.text OR old.kind IS NOT new.kind OR old.mime IS NOT new.mime\n OR old.extractor IS NOT new.extractor OR old.attachable_type IS NOT new.attachable_type)\n AND ((new.attachable_type = 'message' AND (coalesce(new.extractor, '') LIKE 'speech:%'\n OR new.kind IN ('voice', 'video_note') OR coalesce(new.mime, '') = 'audio/ogg'))\n OR (old.attachable_type = 'message' AND (coalesce(old.extractor, '') LIKE 'speech:%'\n OR old.kind IN ('voice', 'video_note') OR coalesce(old.mime, '') = 'audio/ogg'))) BEGIN\n INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id) VALUES ('attachment', new.id);\nEND;",
595
+ "CREATE TRIGGER transcript_index_ad AFTER DELETE ON attachments BEGIN\n DELETE FROM transcript_words WHERE rowid = old.id;\n DELETE FROM transcript_stems WHERE rowid = old.id;\n DELETE FROM transcript_index_pending WHERE indexable_type = 'attachment' AND id = old.id;\nEND;",
596
+ "INSERT OR IGNORE INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version, built_at, analyzer)\n VALUES ('transcript_index', 0, 0, 0, 1, NULL, NULL);",
597
+ "INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id)\n SELECT 'attachment', id FROM attachments WHERE attachable_type = 'message' AND coalesce(text, '') <> ''\n AND (coalesce(extractor, '') LIKE 'speech:%' OR kind IN ('voice', 'video_note') OR coalesce(mime, '') = 'audio/ogg');"
598
+ ]
580
599
  }
581
600
  ];
@@ -15,12 +15,14 @@ export interface NoteIndexState {
15
15
  */
16
16
  export interface Corpus {
17
17
  /** The singular table name: `indexable_type` in the queue, `chunkable_type` in chunks. */
18
- type: "document" | "note" | "memory" | "email" | "task";
18
+ type: "document" | "note" | "memory" | "email" | "task" | "transcript";
19
19
  /** Its row in `search_index_state`. */
20
20
  index: string;
21
21
  table: string;
22
22
  /** Title and body as `title`/`body`, whether it is live, and its scope, for one row by `id`. */
23
23
  read: string;
24
+ /** `indexable_type` in the queue when it is not `type`: the table a queued id points into. */
25
+ queued?: "attachment";
24
26
  /** False when the corpus cuts its own pieces for search by meaning, as mail does by thread. */
25
27
  pieces?: false;
26
28
  /** The words and stems rows, when the corpus cleans its text its own way; `normalize` and `indexText` otherwise. */
@@ -66,6 +68,14 @@ export declare const CORPORA: {
66
68
  stems: string;
67
69
  };
68
70
  };
71
+ readonly transcript: {
72
+ readonly type: "transcript";
73
+ readonly queued: "attachment";
74
+ readonly index: "transcript_index";
75
+ readonly table: `(SELECT a.id FROM attachments a WHERE a.attachable_type = 'message' AND ${string})`;
76
+ readonly read: string;
77
+ readonly pieces: false;
78
+ };
69
79
  };
70
80
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
71
81
  export declare const noteIndexText: (title: string | null, text: string) => string;
@@ -5,6 +5,7 @@ import { normalize } from "../normalize.js";
5
5
  import { dropDocumentPicture } from "./document-pictures.js";
6
6
  import { int } from "./events.js";
7
7
  import { indexRow } from "./index-state.js";
8
+ import { speech } from "./lucene-fields.js";
8
9
  import { savedStemmers } from "./stems.js";
9
10
  import { write } from "./transaction.js";
10
11
  export const CORPORA = {
@@ -45,13 +46,24 @@ export const CORPORA = {
45
46
  pieces: false,
46
47
  terms: (text, stemmer) => ({ words: taskWordsOf(text), stems: [...taskStemsOf(text, stemmer)].join(" ") }),
47
48
  },
49
+ transcript: {
50
+ type: "transcript",
51
+ queued: "attachment",
52
+ index: "transcript_index",
53
+ table: `(SELECT a.id FROM attachments a WHERE a.attachable_type = 'message' AND ${speech("a")})`,
54
+ read: `SELECT NULL AS title, coalesce(a.text, '') AS body, a.attachable_type = 'message' AND ${speech("a")} AS live, ` +
55
+ "'personal' AS scope FROM attachments a WHERE a.id = ?",
56
+ pieces: false,
57
+ },
48
58
  };
49
59
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
50
60
  export const noteIndexText = (title, text) => (title ? `${title}\n\n${text}` : text);
61
+ const queuedAs = (corpus) => corpus.queued ?? corpus.type;
51
62
  export const corpusIndexState = (database, corpus) => {
52
63
  const row = indexRow(database, corpus.index);
53
- const pending = Number(database.prepare(`SELECT count(*) AS n FROM ${corpus.type}_index_pending WHERE indexable_type = ?`).get(corpus.type)
54
- ?.n);
64
+ const pending = Number(database
65
+ .prepare(`SELECT count(*) AS n FROM ${corpus.type}_index_pending WHERE indexable_type = ?`)
66
+ .get(queuedAs(corpus))?.n);
55
67
  const saved = savedStemmers(database);
56
68
  const wanted = saved === null ? null : analyzerIdentity(saved ?? DEFAULT_STEMMERS);
57
69
  const built = row.analyzer === null ? null : String(row.analyzer);
@@ -126,7 +138,7 @@ const writer = (database, corpus, stemmer) => {
126
138
  statements.purge.run(hash, hash);
127
139
  if (!live && corpus.type === "document")
128
140
  dropDocumentPicture(database, id);
129
- statements.dequeue.run(corpus.type, id);
141
+ statements.dequeue.run(queuedAs(corpus), id);
130
142
  };
131
143
  };
132
144
  /**
@@ -154,7 +166,7 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
154
166
  if (state.built !== null)
155
167
  database
156
168
  .prepare(`INSERT OR IGNORE INTO ${queue} (indexable_type, id) SELECT ?, id FROM ${corpus.table}`)
157
- .run(corpus.type);
169
+ .run(queuedAs(corpus));
158
170
  database.prepare("UPDATE search_index_state SET analyzer = ? WHERE name = ?").run(stemmer.identity, corpus.index);
159
171
  });
160
172
  }
@@ -167,7 +179,7 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
167
179
  if (until())
168
180
  break;
169
181
  const count = write(database, () => {
170
- const ids = next.all(corpus.type, batch).map((row) => Number(row.id));
182
+ const ids = next.all(queuedAs(corpus), batch).map((row) => Number(row.id));
171
183
  for (const id of ids)
172
184
  index(id);
173
185
  return ids.length;
@@ -187,7 +199,7 @@ export const resetNoteIndex = (database) => {
187
199
  for (const corpus of Object.values(CORPORA))
188
200
  database
189
201
  .prepare(`INSERT OR IGNORE INTO ${corpus.type}_index_pending (indexable_type, id) SELECT ?, id FROM ${corpus.table}`)
190
- .run(corpus.type);
202
+ .run(queuedAs(corpus));
191
203
  });
192
204
  return true;
193
205
  };
@@ -6497,6 +6497,45 @@ export declare const attachments: import("drizzle-orm/sqlite-core").SQLiteTableW
6497
6497
  };
6498
6498
  dialect: 'sqlite';
6499
6499
  }>;
6500
+ export declare const transcriptIndexPending: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6501
+ name: "transcript_index_pending";
6502
+ schema: undefined;
6503
+ columns: {
6504
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6505
+ name: string;
6506
+ tableName: "transcript_index_pending";
6507
+ dataType: "number int53";
6508
+ data: number;
6509
+ driverParam: number;
6510
+ notNull: true;
6511
+ hasDefault: false;
6512
+ isPrimaryKey: false;
6513
+ isAutoincrement: false;
6514
+ hasRuntimeDefault: false;
6515
+ enumValues: undefined;
6516
+ baseColumn: never;
6517
+ identity: undefined;
6518
+ generated: undefined;
6519
+ }, {}>;
6520
+ indexableType: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6521
+ name: string;
6522
+ tableName: "transcript_index_pending";
6523
+ dataType: "string";
6524
+ data: string;
6525
+ driverParam: string;
6526
+ notNull: true;
6527
+ hasDefault: false;
6528
+ isPrimaryKey: false;
6529
+ isAutoincrement: false;
6530
+ hasRuntimeDefault: false;
6531
+ enumValues: [string, ...string[]];
6532
+ baseColumn: never;
6533
+ identity: undefined;
6534
+ generated: undefined;
6535
+ }, {}>;
6536
+ };
6537
+ dialect: 'sqlite';
6538
+ }>;
6500
6539
  export { documentAnnotationTransfers } from "./schema/document-transfers.js";
6501
6540
  export declare const documents: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6502
6541
  name: "documents";
@@ -715,6 +715,10 @@ export const attachments = sqliteTable("attachments", {
715
715
  .on(table.id)
716
716
  .where(sql `local_path IS NOT NULL AND local_path_folded IS NULL`),
717
717
  ]);
718
+ export const transcriptIndexPending = sqliteTable("transcript_index_pending", {
719
+ id: integer("id").notNull(),
720
+ indexableType: text("indexable_type").notNull(),
721
+ }, (table) => [primaryKey({ columns: [table.indexableType, table.id] })]);
718
722
  export { documentAnnotationTransfers } from "./schema/document-transfers.js";
719
723
  export const documents = sqliteTable("documents", {
720
724
  id: integer("id").primaryKey(),
@@ -1,7 +1,7 @@
1
1
  import type { StoreContext } from "./open.js";
2
2
  /** Unnamed runs kept, newest first; a saved search is never pruned. */
3
3
  export declare const HISTORY_KEPT = 1000;
4
- export type SearchCommand = "search" | "stats" | "message-top" | "author-top" | "admin-statistics";
4
+ export type SearchCommand = "search" | "stats" | "message-top" | "author-top" | "admin-statistics" | "meetings-search";
5
5
  /** What one run or one saved search is: `params` as canonical JSON, so the same run is the same text. */
6
6
  export interface SearchRecord {
7
7
  command: SearchCommand;
@@ -26,9 +26,9 @@ export declare const recordRun: ({ database, now }: StoreContext, run: SearchRec
26
26
  export declare const findSearch: ({ database }: StoreContext, reference: string) => StoredSearch | undefined;
27
27
  /** A name already taken is refused unless `replace`, which starts it afresh: new parameters, no runs. */
28
28
  export declare const saveSearch: (context: StoreContext, name: string, search: SearchRecord, replace: boolean) => StoredSearch;
29
- export declare const savedSearches: ({ database }: StoreContext) => StoredSearch[];
29
+ export declare const savedSearches: ({ database }: StoreContext, commands?: readonly SearchCommand[]) => StoredSearch[];
30
30
  /** Runs, newest first: unnamed ones and saved searches that have run. One more than `limit` says there are more. */
31
- export declare const searchHistory: ({ database }: StoreContext, limit: number, offset?: number) => StoredSearch[];
31
+ export declare const searchHistory: ({ database }: StoreContext, limit: number, offset?: number, commands?: readonly SearchCommand[]) => StoredSearch[];
32
32
  export declare const deleteSearch: (context: StoreContext, reference: string) => StoredSearch;
33
33
  /** Unnamed runs only; saved searches stay. */
34
- export declare const clearHistory: ({ database }: StoreContext) => number;
34
+ export declare const clearHistory: ({ database }: StoreContext, commands?: readonly SearchCommand[]) => number;
@@ -2,6 +2,9 @@ import { CliError, singleLine } from "@wirecat/cli-base";
2
2
  import { toIso } from "./values.js";
3
3
  /** Unnamed runs kept, newest first; a saved search is never pruned. */
4
4
  export const HISTORY_KEPT = 1000;
5
+ const commandFilter = (commands) => commands
6
+ ? { sql: ` AND command IN (${commands.map(() => "?").join(", ")})`, values: [...commands] }
7
+ : { sql: "", values: [] };
5
8
  const COLUMNS = "id, name, command, params, language, version, fields_version, created_at, updated_at, last_run_at, runs";
6
9
  const searchOf = (row) => ({
7
10
  id: String(row.id),
@@ -61,16 +64,28 @@ export const saveSearch = (context, name, search, replace) => {
61
64
  .run(...values, now(), name);
62
65
  return mustFind(context, name);
63
66
  };
64
- export const savedSearches = ({ database }) => database.prepare(`SELECT ${COLUMNS} FROM searches WHERE name IS NOT NULL ORDER BY name`).all().map(searchOf);
67
+ export const savedSearches = ({ database }, commands) => {
68
+ const only = commandFilter(commands);
69
+ return database
70
+ .prepare(`SELECT ${COLUMNS} FROM searches WHERE name IS NOT NULL${only.sql} ORDER BY name`)
71
+ .all(...only.values)
72
+ .map(searchOf);
73
+ };
65
74
  /** Runs, newest first: unnamed ones and saved searches that have run. One more than `limit` says there are more. */
66
- export const searchHistory = ({ database }, limit, offset = 0) => database
67
- .prepare(`SELECT ${COLUMNS} FROM searches WHERE last_run_at IS NOT NULL ORDER BY last_run_at DESC, id DESC LIMIT ? OFFSET ?`)
68
- .all(limit + 1, offset)
69
- .map(searchOf);
75
+ export const searchHistory = ({ database }, limit, offset = 0, commands) => {
76
+ const only = commandFilter(commands);
77
+ return database
78
+ .prepare(`SELECT ${COLUMNS} FROM searches WHERE last_run_at IS NOT NULL${only.sql} ORDER BY last_run_at DESC, id DESC LIMIT ? OFFSET ?`)
79
+ .all(...only.values, limit + 1, offset)
80
+ .map(searchOf);
81
+ };
70
82
  export const deleteSearch = (context, reference) => {
71
83
  const found = mustFind(context, reference);
72
84
  context.database.prepare("DELETE FROM searches WHERE id = ?").run(Number(found.id));
73
85
  return found;
74
86
  };
75
87
  /** Unnamed runs only; saved searches stay. */
76
- export const clearHistory = ({ database }) => database.prepare("DELETE FROM searches WHERE name IS NULL").run().changes;
88
+ export const clearHistory = ({ database }, commands) => {
89
+ const only = commandFilter(commands);
90
+ return database.prepare(`DELETE FROM searches WHERE name IS NULL${only.sql}`).run(...only.values).changes;
91
+ };