@wirecat/cli-core 0.498.0 → 0.500.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 (49) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/messenger/hearing-command.js +1 -1
  4. package/dist/cli/messenger/models-command.js +2 -2
  5. package/dist/cli/messenger/transcribe-command.js +14 -10
  6. package/dist/cli/runs/command.js +2 -1
  7. package/dist/domain/knowledge.d.ts +1 -1
  8. package/dist/domain/knowledge.js +1 -1
  9. package/dist/entities/fact.d.ts +15 -1
  10. package/dist/entities/fact.js +6 -1
  11. package/dist/entities/fields.d.ts +11 -0
  12. package/dist/entities/fields.js +11 -0
  13. package/dist/entities/index.d.ts +25 -1
  14. package/dist/entities/memory.d.ts +14 -0
  15. package/dist/entities/memory.js +6 -1
  16. package/dist/errors/reasons.d.ts +1 -1
  17. package/dist/errors/reasons.js +8 -0
  18. package/dist/meetings/transcription.d.ts +5 -0
  19. package/dist/meetings/transcription.js +27 -6
  20. package/dist/speech/decode.d.ts +11 -1
  21. package/dist/speech/decode.js +20 -15
  22. package/dist/speech/hearing.d.ts +3 -1
  23. package/dist/speech/hearing.js +4 -4
  24. package/dist/speech/index.d.ts +5 -2
  25. package/dist/speech/index.js +4 -2
  26. package/dist/speech/local.d.ts +8 -0
  27. package/dist/speech/local.js +22 -1
  28. package/dist/speech/models.d.ts +2 -0
  29. package/dist/speech/models.js +2 -0
  30. package/dist/speech/openai.d.ts +54 -0
  31. package/dist/speech/openai.js +199 -0
  32. package/dist/speech/recognize.d.ts +25 -1
  33. package/dist/speech/recognize.js +52 -21
  34. package/dist/speech/transcribe.d.ts +25 -9
  35. package/dist/speech/transcribe.js +72 -13
  36. package/dist/speech/wav.d.ts +2 -0
  37. package/dist/speech/wav.js +20 -0
  38. package/dist/store/index.d.ts +1 -0
  39. package/dist/store/project-transfer/request.d.ts +1 -1
  40. package/dist/store/project-transfer/schema.d.ts +2 -2
  41. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  42. package/dist/store/sqlite/evidence-sources.d.ts +23 -0
  43. package/dist/store/sqlite/evidence-sources.js +59 -0
  44. package/dist/store/sqlite/facts.d.ts +16 -0
  45. package/dist/store/sqlite/facts.js +39 -5
  46. package/dist/store/sqlite/memories.d.ts +28 -0
  47. package/dist/store/sqlite/memories.js +62 -3
  48. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  49. package/package.json +1 -1
@@ -31,10 +31,9 @@ export const decodeOgg = async (bytes) => {
31
31
  await decoder.ready;
32
32
  try {
33
33
  const { channelData, sampleRate, samplesDecoded } = await decoder.decodeFile(bytes);
34
- const samples = channelData[0];
35
- if (!samples || samplesDecoded === 0)
34
+ if (!channelData[0] || samplesDecoded === 0)
36
35
  throw new Error("no Ogg Opus audio in it");
37
- return { samples, rate: sampleRate };
36
+ return channelData.map((samples) => ({ samples, rate: sampleRate }));
38
37
  }
39
38
  finally {
40
39
  decoder.free();
@@ -59,6 +58,43 @@ export const detector = (vadModel) => ({
59
58
  debug: 0,
60
59
  bufferSizeInSeconds: 30,
61
60
  });
61
+ /**
62
+ * `pcm` as `toModelRate` made it: the detector's sample offsets count its half-second lead, the recording does not.
63
+ * Each stretch goes to `each` as it is cut, so an hour of speech is never held twice.
64
+ */
65
+ const cut = (vad, pcm, each) => {
66
+ // One detector can hear several recordings in a row; nothing of the last may leak into this one.
67
+ vad.reset();
68
+ const last = Math.max(0, pcm.length - LEAD);
69
+ const drain = () => {
70
+ for (; !vad.isEmpty(); vad.pop()) {
71
+ const { start, samples } = vad.front();
72
+ each({
73
+ start: Math.min(last, Math.max(0, start - LEAD)) / SAMPLE_RATE,
74
+ end: Math.min(last, Math.max(0, start + samples.length - LEAD)) / SAMPLE_RATE,
75
+ samples,
76
+ });
77
+ }
78
+ };
79
+ for (let i = 0; i + WINDOW <= pcm.length; i += WINDOW) {
80
+ vad.acceptWaveform(pcm.subarray(i, i + WINDOW));
81
+ drain();
82
+ }
83
+ vad.flush();
84
+ drain();
85
+ };
86
+ /** The voice detector alone, for a recognizer that is not on this machine. */
87
+ export const openSpeechCutter = (vadModel) => {
88
+ const vad = loadSherpa().createVad(detector(vadModel));
89
+ return {
90
+ cut: (pcm) => {
91
+ const spans = [];
92
+ cut(vad, pcm, ({ start, end }) => spans.push({ start, end }));
93
+ return spans;
94
+ },
95
+ free: () => vad.free(),
96
+ };
97
+ };
62
98
  /**
63
99
  * The model, and a voice detector that cuts the audio at pauses into pieces of at most 20 seconds.
64
100
  * The cutting is not optional: five minutes in one piece crash the WebAssembly build (measured).
@@ -107,25 +143,20 @@ export const openRecognizer = (model, path, vadModel) => {
107
143
  stream.free();
108
144
  }
109
145
  };
146
+ const pieces = (pcm) => {
147
+ const heard = [];
148
+ cut(vad, pcm, ({ start, end, samples }) => {
149
+ const text = piece(samples);
150
+ if (text)
151
+ heard.push({ start, end, text });
152
+ });
153
+ return heard;
154
+ };
110
155
  return {
111
- recognize: (pcm) => {
112
- // One recognizer can hear several recordings in a row; nothing of the last may leak into this one.
113
- vad.reset();
114
- const parts = [];
115
- const drain = () => {
116
- while (!vad.isEmpty()) {
117
- parts.push(piece(vad.front().samples));
118
- vad.pop();
119
- }
120
- };
121
- for (let i = 0; i + WINDOW <= pcm.length; i += WINDOW) {
122
- vad.acceptWaveform(pcm.subarray(i, i + WINDOW));
123
- drain();
124
- }
125
- vad.flush();
126
- drain();
127
- return parts.filter(Boolean).join(" ");
128
- },
156
+ recognize: (pcm) => pieces(pcm)
157
+ .map(({ text }) => text)
158
+ .join(" "),
159
+ pieces,
129
160
  free: () => {
130
161
  vad.free();
131
162
  engine.free();
@@ -1,18 +1,21 @@
1
- import { CliError } from "@wirecat/cli-base";
1
+ import { CliError, type KeyringStore } from "@wirecat/cli-base";
2
2
  import type { Messenger } from "../cli/messenger/context.js";
3
3
  import { type Settings } from "../cli/settings.js";
4
4
  import { type MessengerAdapter, type RemoteFile, type Transcript } from "../ports/messenger.js";
5
5
  import type { Ffmpeg } from "./decode.js";
6
6
  import { type SpeechModel } from "./models.js";
7
7
  import type { Recognizer } from "./recognize.js";
8
- /** `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the second. */
9
- export declare const TRANSCRIBE_WITH: readonly ["auto", "messenger", "local"];
8
+ /**
9
+ * `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the
10
+ * second. `openai`: OpenAI's API, only when asked by name — `auto` never sends audio off the machine.
11
+ */
12
+ export declare const TRANSCRIBE_WITH: readonly ["auto", "messenger", "local", "openai"];
10
13
  export type TranscribeWith = (typeof TRANSCRIBE_WITH)[number];
11
14
  export interface Heard extends Transcript {
12
15
  messageId: string;
13
- /** The messenger's provider, or `local`. */
16
+ /** The messenger's provider, `local` or `openai`. */
14
17
  via: string;
15
- /** The local model, when it was one. */
18
+ /** The local or OpenAI model, when it was one. */
16
19
  model?: string;
17
20
  }
18
21
  export type OpenRecognizer = (model: SpeechModel, path: (name: string) => string, vadModel: string) => Recognizer;
@@ -24,17 +27,26 @@ export interface Choice {
24
27
  /** The local recognizer, where a test hands one in. */
25
28
  open?: OpenRecognizer;
26
29
  ffmpeg?: Ffmpeg;
30
+ /** The profile's `transcribeModel` for `openai`, else `gpt-transcribe`. */
31
+ transcribeModel?: string;
32
+ apiKey?: string;
33
+ /** Why nothing can be heard: an unknown `transcribeWith`, or for `openai` no key or a read-only process. */
34
+ refusal?: CliError;
35
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
36
+ /** The command's own cancel and deadline, for a request that leaves the machine. */
37
+ signal?: AbortSignal;
27
38
  }
28
39
  /** A voice note, or an audio file someone sent — an iPhone recording reaches WhatsApp as `audio/mp4`. */
29
40
  export declare const isAudioFile: ({ kind, mime }: {
30
41
  kind: string;
31
42
  mime?: string | undefined;
32
43
  }) => boolean;
33
- /** The flag, then the profile's `transcribeWith` and `speechModel`, then `auto` and the CLI's first model. */
44
+ /** The flag, then the profile's `transcribeWith`, `speechModel` and `transcribeModel`, then `auto` and the CLI's first model. */
34
45
  export declare const choose: (messenger: Messenger, settings: Pick<Settings, "configured" | "shared">, flags: {
35
46
  local?: boolean;
36
47
  model?: string;
37
- }, env?: NodeJS.ProcessEnv) => Choice;
48
+ with?: TranscribeWith;
49
+ }, env?: NodeJS.ProcessEnv, keyring?: KeyringStore) => Choice;
38
50
  /** Never a download: a model is hundreds of MB, and whether to fetch one is the owner's call. */
39
51
  export declare const notDownloaded: (messenger: Messenger, model: SpeechModel) => CliError;
40
52
  /**
@@ -43,5 +55,9 @@ export declare const notDownloaded: (messenger: Messenger, model: SpeechModel) =
43
55
  * `read` gets the recording once its bytes are all in, for a reference the messenger renewed while they were read.
44
56
  */
45
57
  export declare const hearOnline: (messenger: Messenger, connection: MessengerAdapter, chat: string, messageId: string, choice: Choice, read?: (file: RemoteFile) => Promise<void>) => Promise<Heard | Uint8Array>;
46
- /** The local model, on the bytes `hearOnline` fetched. */
47
- export declare const hearLocally: (bytes: Uint8Array, messageId: string, { model, directory, open, ffmpeg }: Choice) => Promise<Heard>;
58
+ /** The local model, or OpenAI when chosen, on the bytes `hearOnline` fetched. */
59
+ export declare const hearLocally: (bytes: Uint8Array, messageId: string, choice: Choice) => Promise<Heard>;
60
+ /** Where a kept transcript came from, as `Kept` stores it: the messenger's provider, a local model, or `openai:<model>`. */
61
+ export declare const keptSource: (heard: Heard) => string;
62
+ /** The reverse of `keptSource`. */
63
+ export declare const heardVia: (source: string, provider: string) => Pick<Heard, "via" | "model">;
@@ -1,26 +1,52 @@
1
1
  import { CliError, isCliError } from "@wirecat/cli-base";
2
2
  import { fromFile } from "../cli/settings.js";
3
+ import { embeddingKeys } from "../embeddings/keys.js";
4
+ import { readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
3
5
  import { capability } from "../ports/messenger.js";
4
6
  import { typedCommand } from "../runs/scope.js";
5
7
  import { installedBytes, isInstalled, megabytes, modelPath, modelsDirectory, vadPath } from "./install.js";
6
- import { orderedModels, speechModel } from "./models.js";
7
- /** `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the second. */
8
- export const TRANSCRIBE_WITH = ["auto", "messenger", "local"];
8
+ import { OPENAI_TRANSCRIBE_MODEL, orderedModels, speechModel } from "./models.js";
9
+ /**
10
+ * `messenger`: its own recognition (Telegram Premium). `local`: a model on this machine. `auto`: the first, then the
11
+ * second. `openai`: OpenAI's API, only when asked by name — `auto` never sends audio off the machine.
12
+ */
13
+ export const TRANSCRIBE_WITH = ["auto", "messenger", "local", "openai"];
9
14
  /** A recording is read into memory whole; minutes of Opus are a few megabytes. */
10
15
  const LARGEST_VOICE = 32 * 1024 * 1024;
11
16
  /** A voice note, or an audio file someone sent — an iPhone recording reaches WhatsApp as `audio/mp4`. */
12
17
  export const isAudioFile = ({ kind, mime }) => kind === "voice" || kind === "audio" || (mime?.startsWith("audio/") ?? false);
13
- /** The flag, then the profile's `transcribeWith` and `speechModel`, then `auto` and the CLI's first model. */
14
- export const choose = (messenger, settings, flags, env = process.env) => {
18
+ /** The flag, then the profile's `transcribeWith`, `speechModel` and `transcribeModel`, then `auto` and the CLI's first model. */
19
+ export const choose = (messenger, settings, flags, env = process.env, keyring) => {
15
20
  const model = flags.model ?? fromFile(settings, "speechModel", undefined).value;
21
+ const chosen = flags.local || flags.model !== undefined
22
+ ? "local"
23
+ : (flags.with ?? fromFile(settings, "transcribeWith", "auto").value);
24
+ // Refused only where something is heard: `models list` reads the same profile for its model alone.
25
+ const known = TRANSCRIBE_WITH.includes(chosen);
16
26
  return {
17
27
  directory: modelsDirectory(env),
18
- with: flags.local || flags.model !== undefined
19
- ? "local"
20
- : fromFile(settings, "transcribeWith", "auto").value,
28
+ with: known ? chosen : "auto",
29
+ ...(known
30
+ ? {}
31
+ : {
32
+ refusal: new CliError("validation_error", `the profile's transcribeWith is one of ${TRANSCRIBE_WITH.join(", ")}`),
33
+ }),
21
34
  model: model === undefined ? orderedModels(messenger.speechModels)[0] : speechModel(model),
35
+ transcribeModel: fromFile(settings, "transcribeModel", OPENAI_TRANSCRIBE_MODEL).value,
36
+ ...(known && chosen === "openai" ? openaiAccess(messenger, env, keyring) : {}),
22
37
  };
23
38
  };
39
+ const openaiAccess = (messenger, env, keyring) => {
40
+ const lock = readOnlyLockOf(messenger.app, env);
41
+ if (lock)
42
+ return { refusal: readOnlyRefusal(lock, "send audio to OpenAI") };
43
+ const apiKey = embeddingKeys(messenger.app, env, keyring).read("openai")?.key;
44
+ return apiKey
45
+ ? { apiKey }
46
+ : {
47
+ refusal: new CliError("authentication_error", `no OpenAI key — \`${typedCommand(messenger.app.command, "models keys set openai")}\`, or OPENAI_API_KEY`, { reason: "transcription.no-key" }),
48
+ };
49
+ };
24
50
  /** Never a download: a model is hundreds of MB, and whether to fetch one is the owner's call. */
25
51
  export const notDownloaded = (messenger, model) => new CliError("not_found", `the speech model ${model.id} is not on this machine — the owner downloads it once with ` +
26
52
  `\`${typedCommand(messenger.app.command, `models download ${model.id}`)}\` (${megabytes(installedBytes(model))})`, { reason: "model.not-downloaded", model: model.id });
@@ -30,7 +56,9 @@ export const notDownloaded = (messenger, model) => new CliError("not_found", `th
30
56
  * `read` gets the recording once its bytes are all in, for a reference the messenger renewed while they were read.
31
57
  */
32
58
  export const hearOnline = async (messenger, connection, chat, messageId, choice, read) => {
33
- if (choice.with !== "local") {
59
+ if (choice.refusal)
60
+ throw choice.refusal;
61
+ if (choice.with === "auto" || choice.with === "messenger") {
34
62
  const canAsk = typeof connection.transcribe === "function";
35
63
  if (choice.with === "messenger" || canAsk) {
36
64
  try {
@@ -44,7 +72,7 @@ export const hearOnline = async (messenger, connection, chat, messageId, choice,
44
72
  }
45
73
  }
46
74
  }
47
- if (!isInstalled(choice.model, choice.directory))
75
+ if (choice.with !== "openai" && !isInstalled(choice.model, choice.directory))
48
76
  throw notDownloaded(messenger, choice.model);
49
77
  const { files } = await capability(connection, "download", "download attachments")(chat, messageId);
50
78
  const voice = files.find(isAudioFile);
@@ -54,11 +82,34 @@ export const hearOnline = async (messenger, connection, chat, messageId, choice,
54
82
  await read?.(voice);
55
83
  return bytes;
56
84
  };
57
- /** The local model, on the bytes `hearOnline` fetched. */
58
- export const hearLocally = async (bytes, messageId, { model, directory, open, ffmpeg }) => {
59
- const { openRecognizer, toModelRate } = await import("./recognize.js");
85
+ /** The local model, or OpenAI when chosen, on the bytes `hearOnline` fetched. */
86
+ export const hearLocally = async (bytes, messageId, choice) => {
87
+ const { model, directory, open, ffmpeg } = choice;
60
88
  const { decodeAudio } = await import("./decode.js");
61
89
  const { samples, rate } = await decodeAudio(bytes, ffmpeg);
90
+ if (choice.with === "openai") {
91
+ if (choice.refusal)
92
+ throw choice.refusal;
93
+ const { transcribeOpenAI } = await import("./openai.js");
94
+ const transcribeModel = choice.transcribeModel ?? OPENAI_TRANSCRIBE_MODEL;
95
+ const pieces = await transcribeOpenAI({ samples, rate }, {
96
+ apiKey: choice.apiKey ?? "",
97
+ model: transcribeModel,
98
+ vadModel: vadPath(directory),
99
+ // A voice message wants its text, not its turns: one request, and no voice detector needed.
100
+ bundle: true,
101
+ ...(choice.fetch ? { fetch: choice.fetch } : {}),
102
+ ...(choice.signal ? { signal: choice.signal } : {}),
103
+ });
104
+ return {
105
+ messageId,
106
+ text: pieces.map(({ text }) => text).join(" "),
107
+ pending: false,
108
+ via: "openai",
109
+ model: transcribeModel,
110
+ };
111
+ }
112
+ const { openRecognizer, toModelRate } = await import("./recognize.js");
62
113
  const recognizer = (open ?? openRecognizer)(model, modelPath(directory, model), vadPath(directory));
63
114
  try {
64
115
  return {
@@ -73,6 +124,14 @@ export const hearLocally = async (bytes, messageId, { model, directory, open, ff
73
124
  recognizer.free();
74
125
  }
75
126
  };
127
+ /** Where a kept transcript came from, as `Kept` stores it: the messenger's provider, a local model, or `openai:<model>`. */
128
+ export const keptSource = (heard) => heard.via === "openai" ? `openai:${heard.model ?? OPENAI_TRANSCRIBE_MODEL}` : (heard.model ?? heard.via);
129
+ /** The reverse of `keptSource`. */
130
+ export const heardVia = (source, provider) => source === provider
131
+ ? { via: source }
132
+ : source.startsWith("openai:")
133
+ ? { via: "openai", model: source.slice("openai:".length) }
134
+ : { via: "local", model: source };
76
135
  const readAll = async (file) => {
77
136
  const chunks = [];
78
137
  let total = 0;
@@ -25,3 +25,5 @@ export declare const openWavAudio: (plan: WavAudioPlan, input: WavAudioOptions)
25
25
  windows(): AsyncIterable<WavPcmWindow>;
26
26
  close(): Promise<void>;
27
27
  }>;
28
+ /** PCM16 mono WAV of samples in [-1, 1], for a recognizer that takes a file. */
29
+ export declare const encodeWav: (samples: Float32Array, sampleRate?: number) => Uint8Array;
@@ -196,3 +196,23 @@ export const openWavAudio = async (plan, input) => {
196
196
  throw error;
197
197
  }
198
198
  };
199
+ /** PCM16 mono WAV of samples in [-1, 1], for a recognizer that takes a file. */
200
+ export const encodeWav = (samples, sampleRate = 16000) => {
201
+ const bytes = Buffer.alloc(44 + samples.length * 2);
202
+ bytes.write("RIFF", 0, "ascii");
203
+ bytes.writeUInt32LE(bytes.length - 8, 4);
204
+ bytes.write("WAVEfmt ", 8, "ascii");
205
+ bytes.writeUInt32LE(16, 16);
206
+ bytes.writeUInt16LE(1, 20);
207
+ bytes.writeUInt16LE(1, 22);
208
+ bytes.writeUInt32LE(sampleRate, 24);
209
+ bytes.writeUInt32LE(sampleRate * 2, 28);
210
+ bytes.writeUInt16LE(2, 32);
211
+ bytes.writeUInt16LE(16, 34);
212
+ bytes.write("data", 36, "ascii");
213
+ bytes.writeUInt32LE(samples.length * 2, 40);
214
+ samples.forEach((sample, index) => {
215
+ bytes.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample || 0)) * 32767), 44 + index * 2);
216
+ });
217
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length);
218
+ };
@@ -31,6 +31,7 @@ export { CATALOG_QUERY } from "./sqlite/catalog.js";
31
31
  export type { ChatMetadata } from "./sqlite/chats.js";
32
32
  export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
33
33
  export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
34
+ export type { EvidenceSource } from "./sqlite/evidence-sources.js";
34
35
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
35
36
  export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
36
37
  export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
@@ -6,7 +6,7 @@ export declare const projectAnnotationRequest: (request: ProjectAnnotationTarget
6
6
  ref: string;
7
7
  type: "decision" | "fact" | "promise" | "question" | "statement";
8
8
  statement: string;
9
- status: "accepted" | "proposed" | "reversed" | "superseded";
9
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
10
10
  subject: string | null;
11
11
  occurred_at: number | null;
12
12
  supersedes: string | null;
@@ -25,7 +25,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
25
25
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
26
26
  readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
27
27
  readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
28
- readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed"], undefined>;
28
+ readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
29
29
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
30
30
  readonly occurred_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
31
31
  readonly supersedes: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -40,7 +40,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
40
40
  ref: string;
41
41
  type: "decision" | "fact" | "promise" | "question" | "statement";
42
42
  statement: string;
43
- status: "accepted" | "proposed" | "reversed" | "superseded";
43
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
44
44
  subject: string | null;
45
45
  occurred_at: number | null;
46
46
  supersedes: string | null;
@@ -6,7 +6,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
6
6
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
7
7
  readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
8
8
  readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
9
- readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed"], undefined>;
9
+ readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
10
10
  readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
11
11
  readonly occurred_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
12
12
  readonly supersedes: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -21,7 +21,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
21
21
  ref: string;
22
22
  type: "decision" | "fact" | "promise" | "question" | "statement";
23
23
  statement: string;
24
- status: "accepted" | "proposed" | "reversed" | "superseded";
24
+ status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
25
25
  subject: string | null;
26
26
  occurred_at: number | null;
27
27
  supersedes: string | null;
@@ -0,0 +1,23 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** One reference a memory or fact rests on, read back whole enough to judge it without opening it. */
3
+ export interface EvidenceSource {
4
+ /** `null` when the row is gone from the store and its reference with it. */
5
+ ref: string | null;
6
+ kind: string;
7
+ title: string | null;
8
+ sender: string | null;
9
+ at: string | null;
10
+ excerpt: string | null;
11
+ /** The provider the source came through: `telegram`, `email`, `notes`, `zoom`… */
12
+ tool: string | null;
13
+ /** Deleted at its source since it was cited, or no longer in the store. */
14
+ missing: boolean;
15
+ }
16
+ /**
17
+ * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
18
+ * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
19
+ */
20
+ export declare const evidenceSourcesOf: (database: CacheDatabase, from: {
21
+ type: string;
22
+ id: number;
23
+ }) => EvidenceSource[];
@@ -0,0 +1,59 @@
1
+ import { linkKind } from "./link-kinds.js";
2
+ import { referenceOfThing, stateOfThing, storedThing } from "./things.js";
3
+ const EXCERPT_CHARS = 280;
4
+ const excerptOf = (text) => {
5
+ if (text == null)
6
+ return null;
7
+ const flat = String(text).replace(/\s+/g, " ").trim();
8
+ if (!flat)
9
+ return null;
10
+ return flat.length > EXCERPT_CHARS ? `${flat.slice(0, EXCERPT_CHARS - 1)}…` : flat;
11
+ };
12
+ const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
13
+ const textOf = (value) => (value == null || String(value).trim() === "" ? null : String(value));
14
+ const READS = {
15
+ message: "SELECT c.title, coalesce(m.sender_name, i.name) AS sender, m.sent_at AS at, m.text AS body, a.provider AS tool " +
16
+ "FROM messages m JOIN chats c ON c.id = m.chat_id JOIN accounts a ON a.id = c.account_id " +
17
+ "LEFT JOIN identities i ON i.id = m.sender_identity_id WHERE m.id = ?",
18
+ email: "SELECT e.subject AS title, coalesce(e.from_name, e.from_address) AS sender, e.sent_at AS at, " +
19
+ "coalesce(e.body_text, e.snippet) AS body, 'email' AS tool FROM emails e WHERE e.id = ?",
20
+ note: "SELECT title, NULL AS sender, updated_at AS at, body, 'notes' AS tool FROM notes WHERE id = ?",
21
+ document: "SELECT d.title, NULL AS sender, coalesce(d.external_updated_at, d.updated_at) AS at, d.body, a.provider AS tool " +
22
+ "FROM documents d JOIN accounts a ON a.id = d.account_id WHERE d.id = ?",
23
+ meeting: "SELECT m.title, NULL AS sender, m.started_at AS at, m.description AS body, a.provider AS tool " +
24
+ "FROM meetings m JOIN accounts a ON a.id = m.account_id WHERE m.id = ?",
25
+ meeting_transcript: "SELECT m.title, NULL AS sender, m.started_at AS at, NULL AS body, a.provider AS tool FROM meeting_transcripts t " +
26
+ "JOIN meetings m ON m.id = t.meeting_id JOIN accounts a ON a.id = m.account_id WHERE t.id = ?",
27
+ meeting_transcript_row: "SELECT m.title, r.speaker_name AS sender, m.started_at + coalesce(r.start_ms, 0) AS at, r.text AS body, " +
28
+ "a.provider AS tool FROM meeting_transcript_rows r JOIN meeting_transcripts t ON t.id = r.meeting_transcript_id " +
29
+ "JOIN meetings m ON m.id = t.meeting_id JOIN accounts a ON a.id = m.account_id WHERE r.id = ?",
30
+ };
31
+ const KIND = {
32
+ meeting_transcript: "meeting",
33
+ meeting_transcript_row: "meeting",
34
+ };
35
+ /**
36
+ * Every `evidence` link from a memory or fact, in the order they were written, including one whose target was
37
+ * deleted since: `evidenceOf` drops those, so a reader would never learn the record lost a source.
38
+ */
39
+ export const evidenceSourcesOf = (database, from) => database
40
+ .prepare(`SELECT to_type, to_id FROM links WHERE from_type = ? AND from_id = ? AND kind = ${linkKind("evidence")} ` +
41
+ "AND to_id IS NOT NULL ORDER BY id")
42
+ .all(from.type, from.id)
43
+ .map((link) => {
44
+ const thing = storedThing(link.to_type, link.to_id);
45
+ const state = stateOfThing(database, thing);
46
+ const read = READS[thing.type];
47
+ const row = read === undefined || state === "unavailable" ? undefined : database.prepare(read).get(thing.id);
48
+ const gone = state !== "available";
49
+ return {
50
+ ref: referenceOfThing(database, thing) ?? null,
51
+ kind: KIND[thing.type] ?? thing.type,
52
+ title: textOf(row?.title),
53
+ sender: textOf(row?.sender),
54
+ at: iso(row?.at),
55
+ excerpt: gone ? null : excerptOf(row?.body),
56
+ tool: textOf(row?.tool),
57
+ missing: gone,
58
+ };
59
+ });
@@ -1,5 +1,6 @@
1
1
  import { type FACT_STATUSES, FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
2
2
  import type { Actor } from "./actors.js";
3
+ import { type EvidenceSource } from "./evidence-sources.js";
3
4
  import { type Author } from "./memories.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  /** What happened, as its evidence shows; its evidence is `links` of kind `evidence`. */
@@ -12,6 +13,8 @@ export interface Fact {
12
13
  status: (typeof FACT_STATUSES)[number];
13
14
  project: string | null;
14
15
  subject: string | null;
16
+ subjectName: string | null;
17
+ subjectIsOwner: boolean;
15
18
  occurredAt: string | null;
16
19
  supersedes: string | null;
17
20
  /** `name` is a bot's handle; `null` for a person. */
@@ -61,6 +64,19 @@ export interface FactsStore {
61
64
  accept(reference: string): Promise<Fact>;
62
65
  /** An accepted decision no longer holds and nothing replaced it. */
63
66
  reverse(reference: string): Promise<Fact>;
67
+ /** The owner turns down a proposed fact; it stays, marked `rejected`. */
68
+ reject(reference: string): Promise<Fact>;
69
+ /**
70
+ * Erases the fact: its row, its links, its tags, and the memory it was migrated from, which still held its text.
71
+ * A decision that replaced it no longer names it. A backup made before keeps it.
72
+ */
73
+ remove(reference: string): Promise<{
74
+ ref: string;
75
+ removed: true;
76
+ links: number;
77
+ }>;
78
+ /** Each reference it rests on, read back; one deleted since is `missing`. */
79
+ sources(reference: string): Promise<EvidenceSource[]>;
64
80
  }
65
81
  type FactOperations = {
66
82
  [K in keyof FactsStore]: FactsStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
@@ -1,10 +1,10 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
+ import { evidenceSourcesOf } from "./evidence-sources.js";
4
5
  import { linkKind } from "./link-kinds.js";
5
- import { authorActor, authorOf, evidenceOf, linkEvidence } from "./memories.js";
6
+ import { authorActor, authorOf, dropLinksOf, erase, evidenceOf, linkEvidence, subjectOf, } from "./memories.js";
6
7
  import { requiredThing } from "./notes.js";
7
- import { referenceOfThing, storedThing } from "./things.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";
10
10
  const iso = (value) => new Date(Number(value)).toISOString();
@@ -22,9 +22,7 @@ export const factOperationsOver = (context) => {
22
22
  statement: String(row.statement),
23
23
  status: row.status,
24
24
  project: row.project_id == null ? null : `project:${row.project_id}`,
25
- subject: row.subject_type == null
26
- ? null
27
- : (referenceOfThing(database, storedThing(row.subject_type, row.subject_id)) ?? null),
25
+ ...subjectOf(database, row),
28
26
  occurredAt: row.occurred_at == null ? null : iso(row.occurred_at),
29
27
  supersedes: row.supersedes_id == null ? null : `fact:${row.supersedes_id}`,
30
28
  author: authorOf(row),
@@ -125,8 +123,41 @@ export const factOperationsOver = (context) => {
125
123
  database.prepare("UPDATE facts SET status = 'reversed', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
126
124
  return factOf(rowOf(reference));
127
125
  },
126
+ reject: (reference) => {
127
+ const row = rowOf(reference);
128
+ if (row.status !== "proposed")
129
+ throw new CliError("validation_error", `only a proposed fact can be rejected; this one is ${String(row.status)}`);
130
+ database.prepare("UPDATE facts SET status = 'rejected', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
131
+ return factOf(rowOf(reference));
132
+ },
133
+ remove: (reference) => {
134
+ const row = rowOf(reference);
135
+ const id = Number(row.id);
136
+ const twin = row.source === "memory" && row.metadata != null ? migratedFrom(String(row.metadata)) : undefined;
137
+ const links = erase(database, () => {
138
+ database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
139
+ const count = dropLinksOf(database, { type: "fact", id });
140
+ database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
141
+ database.prepare("DELETE FROM taggings WHERE taggable_type = 'fact' AND taggable_id = ?").run(id);
142
+ database.prepare("DELETE FROM facts WHERE id = ?").run(id);
143
+ if (twin !== undefined)
144
+ database.prepare("DELETE FROM memories WHERE id = ? AND deleted_at IS NOT NULL").run(twin);
145
+ return count;
146
+ });
147
+ return { ref: `fact:${id}`, removed: true, links };
148
+ },
149
+ sources: (reference) => evidenceSourcesOf(database, { type: "fact", id: Number(rowOf(reference).id) }),
128
150
  };
129
151
  };
152
+ const migratedFrom = (metadata) => {
153
+ try {
154
+ const { memory } = JSON.parse(metadata);
155
+ return Number.isInteger(memory) ? Number(memory) : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ };
130
161
  export const factsStoreOver = (context) => {
131
162
  const operations = factOperationsOver(context);
132
163
  return {
@@ -135,5 +166,8 @@ export const factsStoreOver = (context) => {
135
166
  list: async (options) => operations.list(options),
136
167
  accept: async (reference) => operations.accept(reference),
137
168
  reverse: async (reference) => operations.reverse(reference),
169
+ reject: async (reference) => operations.reject(reference),
170
+ remove: async (reference) => operations.remove(reference),
171
+ sources: async (reference) => operations.sources(reference),
138
172
  };
139
173
  };
@@ -1,5 +1,6 @@
1
1
  import { MEMORY_KINDS, MEMORY_SCOPES, type MEMORY_STATUSES } from "../../domain/knowledge.js";
2
2
  import { type Actor } from "./actors.js";
3
+ import { type EvidenceSource } from "./evidence-sources.js";
3
4
  import { type NoteIndexState } from "./note-index.js";
4
5
  import type { StoreContext } from "./open.js";
5
6
  /** Who wrote it: the owner, or an agent by its bot handle and the model it ran on. */
@@ -14,6 +15,10 @@ export interface Memory {
14
15
  body: string;
15
16
  /** What it is about, as a reference; `null` for a general fact. */
16
17
  subject: string | null;
18
+ /** What the subject is called; `null` for the owner, who has no name in the store, and for a nameless row. */
19
+ subjectName: string | null;
20
+ /** The subject is the owner. */
21
+ subjectIsOwner: boolean;
17
22
  /** `name` is a bot's handle; `null` for a person. */
18
23
  author: {
19
24
  type: Actor["type"];
@@ -59,6 +64,17 @@ export interface MemoriesStore {
59
64
  confirm(reference: string): Promise<Memory>;
60
65
  /** Its evidence no longer holds. */
61
66
  markStale(reference: string): Promise<Memory>;
67
+ /**
68
+ * Erases the memory: its row, its text in the search and meaning indexes, and every link from or to it. A memory
69
+ * that replaced it no longer names it. A backup made before keeps it.
70
+ */
71
+ remove(reference: string): Promise<{
72
+ ref: string;
73
+ removed: true;
74
+ links: number;
75
+ }>;
76
+ /** Each reference it rests on, read back; one deleted since is `missing`. */
77
+ sources(reference: string): Promise<EvidenceSource[]>;
62
78
  /** Indexes what was written since, then finds memories holding every word or a longer one it starts, best first. */
63
79
  search(text: string, options?: {
64
80
  subject?: string;
@@ -82,5 +98,17 @@ export declare const evidenceOf: (context: StoreContext, from: {
82
98
  type: string;
83
99
  id: number;
84
100
  }) => string[];
101
+ /** What a memory's or fact's subject is called, for a reader who should not have to look the reference up. */
102
+ export declare const subjectOf: (database: StoreContext["database"], row: Row) => Pick<Memory, "subject" | "subjectName" | "subjectIsOwner">;
103
+ /** Counts every link from or to a row and drops the ones its delete trigger does not: those pointing at it. */
104
+ export declare const dropLinksOf: (database: StoreContext["database"], thing: {
105
+ type: string;
106
+ id: number;
107
+ }) => number;
108
+ /**
109
+ * Runs a delete so the text does not linger in the file: freed pages are zeroed, the words indexes merged so no
110
+ * segment still holds the erased words, and the write-ahead log emptied when no reader holds it.
111
+ */
112
+ export declare const erase: <T>(database: StoreContext["database"], work: () => T) => T;
85
113
  export declare const memoriesStoreOver: (context: StoreContext) => MemoriesStore;
86
114
  export {};