@rubytech/taskmaster 1.2.1 → 1.4.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 (92) hide show
  1. package/dist/agents/auth-profiles/oauth.js +24 -0
  2. package/dist/agents/auth-profiles/profiles.js +37 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/pi-tools.policy.js +4 -0
  5. package/dist/agents/taskmaster-tools.js +14 -0
  6. package/dist/agents/tool-policy.js +5 -2
  7. package/dist/agents/tools/apikeys-tool.js +16 -5
  8. package/dist/agents/tools/contact-create-tool.js +59 -0
  9. package/dist/agents/tools/contact-delete-tool.js +48 -0
  10. package/dist/agents/tools/contact-update-tool.js +17 -2
  11. package/dist/agents/tools/file-delete-tool.js +137 -0
  12. package/dist/agents/tools/file-list-tool.js +127 -0
  13. package/dist/agents/tools/message-history-tool.js +2 -3
  14. package/dist/auto-reply/media-note.js +11 -0
  15. package/dist/auto-reply/reply/commands-tts.js +7 -2
  16. package/dist/auto-reply/reply/get-reply.js +4 -0
  17. package/dist/build-info.json +3 -3
  18. package/dist/cli/provision-seed.js +1 -2
  19. package/dist/commands/doctor-config-flow.js +13 -0
  20. package/dist/config/agent-tools-reconcile.js +53 -0
  21. package/dist/config/defaults.js +10 -1
  22. package/dist/config/legacy.migrations.part-3.js +26 -0
  23. package/dist/config/zod-schema.core.js +9 -1
  24. package/dist/config/zod-schema.js +1 -0
  25. package/dist/control-ui/assets/{index-N8du4fwV.js → index-BDETQp97.js} +692 -600
  26. package/dist/control-ui/assets/index-BDETQp97.js.map +1 -0
  27. package/dist/control-ui/assets/index-CPawOl_z.css +1 -0
  28. package/dist/control-ui/index.html +2 -2
  29. package/dist/gateway/chat-sanitize.js +5 -1
  30. package/dist/gateway/config-reload.js +1 -0
  31. package/dist/gateway/media-http.js +28 -0
  32. package/dist/gateway/server/tls.js +2 -2
  33. package/dist/gateway/server-http.js +34 -4
  34. package/dist/gateway/server-methods/apikeys.js +56 -4
  35. package/dist/gateway/server-methods/chat.js +64 -25
  36. package/dist/gateway/server-methods/tts.js +11 -2
  37. package/dist/gateway/server.impl.js +38 -5
  38. package/dist/infra/tls/gateway.js +19 -3
  39. package/dist/media-understanding/apply.js +35 -0
  40. package/dist/media-understanding/providers/deepgram/audio.js +1 -1
  41. package/dist/media-understanding/providers/google/audio.js +1 -1
  42. package/dist/media-understanding/providers/google/video.js +1 -1
  43. package/dist/media-understanding/providers/index.js +2 -0
  44. package/dist/media-understanding/providers/openai/audio.js +1 -1
  45. package/dist/media-understanding/providers/sherpa-onnx/index.js +10 -0
  46. package/dist/media-understanding/runner.js +61 -72
  47. package/dist/media-understanding/sherpa-onnx-local.js +223 -0
  48. package/dist/memory/audit.js +9 -0
  49. package/dist/memory/manager.js +1 -1
  50. package/dist/records/records-manager.js +10 -0
  51. package/dist/tts/tts.js +98 -10
  52. package/dist/web/auto-reply/monitor/process-message.js +45 -17
  53. package/dist/web/inbound/monitor.js +9 -1
  54. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -0
  55. package/extensions/googlechat/node_modules/.bin/taskmaster +2 -2
  56. package/extensions/googlechat/package.json +2 -2
  57. package/extensions/line/node_modules/.bin/taskmaster +2 -2
  58. package/extensions/line/package.json +1 -1
  59. package/extensions/matrix/node_modules/.bin/markdown-it +0 -0
  60. package/extensions/matrix/node_modules/.bin/taskmaster +2 -2
  61. package/extensions/matrix/package.json +1 -1
  62. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -0
  63. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -0
  64. package/extensions/msteams/node_modules/.bin/taskmaster +2 -2
  65. package/extensions/msteams/package.json +1 -1
  66. package/extensions/nostr/node_modules/.bin/taskmaster +2 -2
  67. package/extensions/nostr/node_modules/.bin/tsc +0 -0
  68. package/extensions/nostr/node_modules/.bin/tsserver +0 -0
  69. package/extensions/nostr/package.json +1 -1
  70. package/extensions/zalo/node_modules/.bin/taskmaster +2 -2
  71. package/extensions/zalo/package.json +1 -1
  72. package/extensions/zalouser/node_modules/.bin/taskmaster +2 -2
  73. package/extensions/zalouser/package.json +1 -1
  74. package/package.json +56 -65
  75. package/scripts/install.sh +0 -0
  76. package/scripts/postinstall.js +76 -0
  77. package/skills/business-assistant/references/crm.md +32 -8
  78. package/taskmaster-docs/USER-GUIDE.md +111 -6
  79. package/templates/.DS_Store +0 -0
  80. package/templates/beagle/agents/admin/AGENTS.md +4 -2
  81. package/templates/customer/.DS_Store +0 -0
  82. package/templates/customer/agents/.DS_Store +0 -0
  83. package/templates/maxy/.DS_Store +0 -0
  84. package/templates/maxy/.gitignore +1 -0
  85. package/templates/maxy/agents/.DS_Store +0 -0
  86. package/templates/maxy/agents/admin/.DS_Store +0 -0
  87. package/templates/maxy/memory/.DS_Store +0 -0
  88. package/templates/maxy/skills/.DS_Store +0 -0
  89. package/templates/taskmaster/.gitignore +1 -0
  90. package/templates/taskmaster/agents/admin/AGENTS.md +1 -0
  91. package/dist/control-ui/assets/index-DtQHRIVD.css +0 -1
  92. package/dist/control-ui/assets/index-N8du4fwV.js.map +0 -1
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { findModelInCatalog, loadModelCatalog, modelSupportsVision, } from "../agents/model-catalog.js";
6
6
  import { applyTemplate } from "../auto-reply/templating.js";
7
7
  import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
8
- import { logVerbose, shouldLogVerbose } from "../globals.js";
8
+ import { createSubsystemLogger } from "../logging/subsystem.js";
9
9
  import { runExec } from "../process/exec.js";
10
10
  import { MediaAttachmentCache, normalizeAttachments, selectAttachments } from "./attachments.js";
11
11
  import { CLI_OUTPUT_MAX_BUFFER, DEFAULT_AUDIO_MODELS, DEFAULT_TIMEOUT_SECONDS, } from "./defaults.js";
@@ -23,6 +23,7 @@ const DEFAULT_IMAGE_MODELS = {
23
23
  google: "gemini-3-flash-preview",
24
24
  minimax: "MiniMax-VL-01",
25
25
  };
26
+ const log = createSubsystemLogger("gateway/media");
26
27
  export function buildProviderRegistry(overrides) {
27
28
  return buildMediaUnderstandingRegistry(overrides);
28
29
  }
@@ -33,7 +34,6 @@ export function createMediaAttachmentCache(attachments) {
33
34
  return new MediaAttachmentCache(attachments);
34
35
  }
35
36
  const binaryCache = new Map();
36
- const geminiProbeCache = new Map();
37
37
  function expandHomeDir(value) {
38
38
  if (!value.startsWith("~"))
39
39
  return value;
@@ -181,26 +181,6 @@ function extractSherpaOnnxText(raw) {
181
181
  }
182
182
  return null;
183
183
  }
184
- async function probeGeminiCli() {
185
- const cached = geminiProbeCache.get("gemini");
186
- if (cached)
187
- return cached;
188
- const resolved = (async () => {
189
- if (!(await hasBinary("gemini")))
190
- return false;
191
- try {
192
- const { stdout } = await runExec("gemini", ["--output-format", "json", "ok"], {
193
- timeoutMs: 8000,
194
- });
195
- return Boolean(extractGeminiResponse(stdout) ?? stdout.toLowerCase().includes("ok"));
196
- }
197
- catch {
198
- return false;
199
- }
200
- })();
201
- geminiProbeCache.set("gemini", resolved);
202
- return resolved;
203
- }
204
184
  async function resolveLocalWhisperCppEntry() {
205
185
  if (!(await hasBinary("whisper-cli")))
206
186
  return null;
@@ -234,7 +214,34 @@ async function resolveLocalWhisperEntry() {
234
214
  ],
235
215
  };
236
216
  }
237
- async function resolveSherpaOnnxEntry() {
217
+ /**
218
+ * Check if sherpa-onnx-node (npm package) is available with model + ffmpeg.
219
+ * Returns a provider entry so the pipeline uses the Node.js API directly
220
+ * (no CLI binary or SHERPA_ONNX_MODEL_DIR env var required).
221
+ */
222
+ async function resolveSherpaOnnxNodeEntry() {
223
+ try {
224
+ const { isReady } = await import("./sherpa-onnx-local.js");
225
+ if (await isReady()) {
226
+ return { type: "provider", provider: "sherpa-onnx" };
227
+ }
228
+ // Package + ffmpeg available but model not yet downloaded — still viable
229
+ // (the provider will trigger a lazy download on first use)
230
+ const { isAvailable } = await import("./sherpa-onnx-local.js");
231
+ if (await isAvailable()) {
232
+ return { type: "provider", provider: "sherpa-onnx" };
233
+ }
234
+ }
235
+ catch {
236
+ // sherpa-onnx-node not installed — skip
237
+ }
238
+ return null;
239
+ }
240
+ /**
241
+ * Fallback: check for sherpa-onnx-offline CLI binary + SHERPA_ONNX_MODEL_DIR env var.
242
+ * This is the legacy detection path for users who installed the binary manually.
243
+ */
244
+ async function resolveSherpaOnnxCliEntry() {
238
245
  if (!(await hasBinary("sherpa-onnx-offline")))
239
246
  return null;
240
247
  const modelDir = process.env.SHERPA_ONNX_MODEL_DIR?.trim();
@@ -265,32 +272,19 @@ async function resolveSherpaOnnxEntry() {
265
272
  };
266
273
  }
267
274
  async function resolveLocalAudioEntry() {
268
- const sherpa = await resolveSherpaOnnxEntry();
269
- if (sherpa)
270
- return sherpa;
275
+ // Prefer sherpa-onnx-node (npm, no PATH issues, automatic model management)
276
+ const sherpaNode = await resolveSherpaOnnxNodeEntry();
277
+ if (sherpaNode)
278
+ return sherpaNode;
279
+ // Fallback: CLI binary (legacy/manual installs)
280
+ const sherpaCli = await resolveSherpaOnnxCliEntry();
281
+ if (sherpaCli)
282
+ return sherpaCli;
271
283
  const whisperCpp = await resolveLocalWhisperCppEntry();
272
284
  if (whisperCpp)
273
285
  return whisperCpp;
274
286
  return await resolveLocalWhisperEntry();
275
287
  }
276
- async function resolveGeminiCliEntry(_capability) {
277
- if (!(await probeGeminiCli()))
278
- return null;
279
- return {
280
- type: "cli",
281
- command: "gemini",
282
- args: [
283
- "--output-format",
284
- "json",
285
- "--allowed-tools",
286
- "read_many_files",
287
- "--include-directories",
288
- "{{MediaDir}}",
289
- "{{Prompt}}",
290
- "Use read_many_files to read {{MediaPath}} and respond with only the text output.",
291
- ],
292
- };
293
- }
294
288
  async function resolveKeyEntry(params) {
295
289
  const { cfg, agentDir, providerRegistry, capability } = params;
296
290
  const checkProvider = async (providerId, model) => {
@@ -362,9 +356,6 @@ async function resolveAutoEntries(params) {
362
356
  if (localAudio)
363
357
  return [localAudio];
364
358
  }
365
- const gemini = await resolveGeminiCliEntry(params.capability);
366
- if (gemini)
367
- return [gemini];
368
359
  const keys = await resolveKeyEntry(params);
369
360
  if (keys)
370
361
  return [keys];
@@ -635,14 +626,18 @@ async function runProviderEntry(params) {
635
626
  maxBytes,
636
627
  timeoutMs,
637
628
  });
638
- const auth = await resolveApiKeyForProvider({
639
- provider: providerId,
640
- cfg,
641
- profileId: entry.profile,
642
- preferredProfile: entry.preferredProfile,
643
- agentDir: params.agentDir,
644
- });
645
- const apiKey = requireApiKey(auth, providerId);
629
+ // Local providers (e.g. sherpa-onnx) do not require an API key.
630
+ let apiKey;
631
+ if (!provider.isLocal) {
632
+ const auth = await resolveApiKeyForProvider({
633
+ provider: providerId,
634
+ cfg,
635
+ profileId: entry.profile,
636
+ preferredProfile: entry.preferredProfile,
637
+ agentDir: params.agentDir,
638
+ });
639
+ apiKey = requireApiKey(auth, providerId);
640
+ }
646
641
  const providerConfig = cfg.models?.providers?.[providerId];
647
642
  const baseUrl = entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl;
648
643
  const mergedHeaders = {
@@ -751,9 +746,7 @@ async function runCliEntry(params) {
751
746
  };
752
747
  const argv = [command, ...args].map((part, index) => index === 0 ? part : applyTemplate(part, templCtx));
753
748
  try {
754
- if (shouldLogVerbose()) {
755
- logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
756
- }
749
+ log.debug(`CLI: ${argv.join(" ")}`);
757
750
  const { stdout } = await runExec(argv[0], argv.slice(1), {
758
751
  timeoutMs,
759
752
  maxBuffer: CLI_OUTPUT_MAX_BUFFER,
@@ -825,9 +818,7 @@ async function runAttachmentEntries(params) {
825
818
  outcome: "skipped",
826
819
  reason: `${err.reason}: ${err.message}`,
827
820
  }));
828
- if (shouldLogVerbose()) {
829
- logVerbose(`Skipping ${capability} model due to ${err.reason}: ${err.message}`);
830
- }
821
+ log.debug(`Skipping ${capability} model: ${err.reason}: ${err.message}`);
831
822
  continue;
832
823
  }
833
824
  attempts.push(buildModelDecision({
@@ -836,9 +827,7 @@ async function runAttachmentEntries(params) {
836
827
  outcome: "failed",
837
828
  reason: String(err),
838
829
  }));
839
- if (shouldLogVerbose()) {
840
- logVerbose(`${capability} understanding failed: ${String(err)}`);
841
- }
830
+ log.error(`${capability} failed: ${String(err)}`);
842
831
  }
843
832
  }
844
833
  return { output: null, attempts };
@@ -866,9 +855,7 @@ export async function runCapability(params) {
866
855
  }
867
856
  const scopeDecision = resolveScopeDecision({ scope: config?.scope, ctx });
868
857
  if (scopeDecision === "deny") {
869
- if (shouldLogVerbose()) {
870
- logVerbose(`${capability} understanding disabled by scope policy.`);
871
- }
858
+ log.debug(`${capability} disabled by scope policy`);
872
859
  return {
873
860
  outputs: [],
874
861
  decision: {
@@ -885,9 +872,7 @@ export async function runCapability(params) {
885
872
  const catalog = await loadModelCatalog({ config: cfg });
886
873
  const entry = findModelInCatalog(catalog, activeProvider, params.activeModel?.model ?? "");
887
874
  if (modelSupportsVision(entry)) {
888
- if (shouldLogVerbose()) {
889
- logVerbose("Skipping image understanding: primary model supports vision natively");
890
- }
875
+ log.debug("Skipping image understanding: primary model supports vision natively");
891
876
  const model = params.activeModel?.model?.trim();
892
877
  const reason = "primary model supports vision natively";
893
878
  return {
@@ -966,8 +951,12 @@ export async function runCapability(params) {
966
951
  outcome: outputs.length > 0 ? "success" : "skipped",
967
952
  attachments: attachmentDecisions,
968
953
  };
969
- if (shouldLogVerbose()) {
970
- logVerbose(`Media understanding ${formatDecisionSummary(decision)}`);
954
+ const summary = formatDecisionSummary(decision);
955
+ if (decision.outcome === "success") {
956
+ log.info(summary);
957
+ }
958
+ else {
959
+ log.debug(summary);
971
960
  }
972
961
  return {
973
962
  outputs,
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Local speech-to-text using sherpa-onnx-node (optional dependency).
3
+ *
4
+ * Model: NeMo CTC English Conformer Small (int8)
5
+ * Storage: ~/.taskmaster/lib/sherpa-onnx/nemo-ctc-en-conformer-small/
6
+ *
7
+ * Requires: sherpa-onnx-node (optional dep), ffmpeg (for OGG→WAV conversion)
8
+ */
9
+ import { execFile as execFileCb, spawnSync } from "node:child_process";
10
+ import fs from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { promisify } from "node:util";
14
+ const execFile = promisify(execFileCb);
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+ const LIB_DIR = path.join(os.homedir(), ".taskmaster", "lib", "sherpa-onnx");
19
+ const MODEL_DIR_NAME = "nemo-ctc-en-conformer-small";
20
+ const MODEL_DIR = path.join(LIB_DIR, MODEL_DIR_NAME);
21
+ const MODEL_FILE = "model.int8.onnx";
22
+ const TOKENS_FILE = "tokens.txt";
23
+ const MODEL_ARCHIVE_URL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-ctc-en-conformer-small.tar.bz2";
24
+ const ARCHIVE_INNER_DIR = "sherpa-onnx-nemo-ctc-en-conformer-small";
25
+ export const MODEL_LABEL = "sherpa-onnx/nemo-ctc-en-conformer-small-int8";
26
+ let sherpaModule = null;
27
+ let sherpaModuleError = null;
28
+ async function importSherpaOnnx() {
29
+ if (sherpaModule)
30
+ return sherpaModule;
31
+ if (sherpaModuleError)
32
+ throw sherpaModuleError;
33
+ try {
34
+ const mod = await import("sherpa-onnx-node");
35
+ // ESM dynamic import puts named exports under .default
36
+ sherpaModule = (mod.default ?? mod);
37
+ return sherpaModule;
38
+ }
39
+ catch (err) {
40
+ sherpaModuleError = err instanceof Error ? err : new Error(String(err));
41
+ throw sherpaModuleError;
42
+ }
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Availability checks
46
+ // ---------------------------------------------------------------------------
47
+ let ffmpegAvailable = null;
48
+ function checkFfmpeg() {
49
+ if (ffmpegAvailable !== null)
50
+ return ffmpegAvailable;
51
+ const result = spawnSync("ffmpeg", ["-version"], { stdio: "ignore", timeout: 5_000 });
52
+ ffmpegAvailable = result.status === 0;
53
+ return ffmpegAvailable;
54
+ }
55
+ async function fileExists(filePath) {
56
+ try {
57
+ await fs.stat(filePath);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ export async function isModelDownloaded() {
65
+ return ((await fileExists(path.join(MODEL_DIR, MODEL_FILE))) &&
66
+ (await fileExists(path.join(MODEL_DIR, TOKENS_FILE))));
67
+ }
68
+ /**
69
+ * Returns true when both the npm package and ffmpeg are available.
70
+ * Does NOT check whether the model is downloaded (use isReady for that).
71
+ */
72
+ export async function isAvailable() {
73
+ try {
74
+ await importSherpaOnnx();
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ return checkFfmpeg();
80
+ }
81
+ /**
82
+ * Returns true when everything is ready for immediate transcription:
83
+ * npm package + ffmpeg + model files on disk.
84
+ */
85
+ export async function isReady() {
86
+ return (await isAvailable()) && (await isModelDownloaded());
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Model download
90
+ // ---------------------------------------------------------------------------
91
+ let downloadInFlight = null;
92
+ /**
93
+ * Download and extract the model if not already present.
94
+ * Deduplicates concurrent calls.
95
+ */
96
+ export async function ensureModel() {
97
+ if (await isModelDownloaded())
98
+ return MODEL_DIR;
99
+ if (!downloadInFlight) {
100
+ downloadInFlight = downloadModelOnce().finally(() => {
101
+ downloadInFlight = null;
102
+ });
103
+ }
104
+ await downloadInFlight;
105
+ return MODEL_DIR;
106
+ }
107
+ async function downloadModelOnce() {
108
+ console.log("[sherpa-onnx] Downloading speech recognition model…");
109
+ await fs.mkdir(LIB_DIR, { recursive: true });
110
+ const archivePath = path.join(LIB_DIR, `${MODEL_DIR_NAME}.tar.bz2`);
111
+ try {
112
+ // Download archive using curl (available on macOS and Linux)
113
+ await execFile("curl", ["-fsSL", "-o", archivePath, MODEL_ARCHIVE_URL], {
114
+ timeout: 300_000,
115
+ });
116
+ // Extract — archive contains sherpa-onnx-nemo-ctc-en-conformer-small/{files}
117
+ await execFile("tar", ["-xjf", archivePath, "-C", LIB_DIR], {
118
+ timeout: 120_000,
119
+ });
120
+ // Rename extracted directory to our standard name (remove sherpa-onnx- prefix)
121
+ const extractedDir = path.join(LIB_DIR, ARCHIVE_INNER_DIR);
122
+ if ((await fileExists(extractedDir)) && extractedDir !== MODEL_DIR) {
123
+ // If target already exists (partial state), remove it first
124
+ if (await fileExists(MODEL_DIR)) {
125
+ await fs.rm(MODEL_DIR, { recursive: true });
126
+ }
127
+ await fs.rename(extractedDir, MODEL_DIR);
128
+ }
129
+ // Clean up: remove archive and unnecessary files to save disk space
130
+ await fs.unlink(archivePath).catch(() => { });
131
+ // Remove full-precision model (keep only int8 — saves ~37MB)
132
+ await fs.unlink(path.join(MODEL_DIR, "model.onnx")).catch(() => { });
133
+ // Remove test wav files
134
+ await fs
135
+ .rm(path.join(MODEL_DIR, "test_wavs"), { recursive: true, force: true })
136
+ .catch(() => { });
137
+ console.log("[sherpa-onnx] Model installed");
138
+ }
139
+ catch (err) {
140
+ // Clean up partial download
141
+ await fs.unlink(archivePath).catch(() => { });
142
+ await fs.rm(MODEL_DIR, { recursive: true, force: true }).catch(() => { });
143
+ throw new Error(`sherpa-onnx model download failed: ${String(err)}`);
144
+ }
145
+ }
146
+ // ---------------------------------------------------------------------------
147
+ // Audio conversion (OGG/Opus → WAV 16kHz mono)
148
+ // ---------------------------------------------------------------------------
149
+ async function convertToWav(inputPath, outputPath) {
150
+ await execFile("ffmpeg", [
151
+ "-i",
152
+ inputPath,
153
+ "-ar",
154
+ "16000",
155
+ "-ac",
156
+ "1",
157
+ "-sample_fmt",
158
+ "s16",
159
+ "-f",
160
+ "wav",
161
+ "-y",
162
+ outputPath,
163
+ ], { timeout: 30_000 });
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Recognizer (cached singleton)
167
+ // ---------------------------------------------------------------------------
168
+ let recognizer = null;
169
+ async function getRecognizer() {
170
+ if (recognizer)
171
+ return recognizer;
172
+ const sherpa = await importSherpaOnnx();
173
+ const modelDir = await ensureModel();
174
+ const config = {
175
+ featConfig: { sampleRate: 16000, featureDim: 80 },
176
+ modelConfig: {
177
+ nemoCtc: {
178
+ model: path.join(modelDir, MODEL_FILE),
179
+ },
180
+ tokens: path.join(modelDir, TOKENS_FILE),
181
+ numThreads: 2,
182
+ provider: "cpu",
183
+ debug: 0,
184
+ },
185
+ };
186
+ recognizer = new sherpa.OfflineRecognizer(config);
187
+ return recognizer;
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Public transcription API
191
+ // ---------------------------------------------------------------------------
192
+ /**
193
+ * Transcribe an audio buffer (any format ffmpeg can read) to text.
194
+ * Returns the transcription text, or throws on failure.
195
+ */
196
+ export async function transcribeLocal(buffer, fileName) {
197
+ const sherpa = await importSherpaOnnx();
198
+ const rec = await getRecognizer();
199
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sherpa-stt-"));
200
+ try {
201
+ const inputPath = path.join(tmpDir, fileName || "audio.ogg");
202
+ await fs.writeFile(inputPath, buffer);
203
+ const wavPath = path.join(tmpDir, "audio.wav");
204
+ await convertToWav(inputPath, wavPath);
205
+ const wave = sherpa.readWave(wavPath);
206
+ const stream = rec.createStream();
207
+ stream.acceptWaveform({ sampleRate: wave.sampleRate, samples: wave.samples });
208
+ rec.decode(stream);
209
+ const result = rec.getResult(stream);
210
+ const text = result?.text?.trim() ?? "";
211
+ if (!text) {
212
+ throw new Error("sherpa-onnx returned empty transcription");
213
+ }
214
+ return { text, model: MODEL_LABEL };
215
+ }
216
+ finally {
217
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
218
+ }
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Exported paths (for postinstall and diagnostics)
222
+ // ---------------------------------------------------------------------------
223
+ export { LIB_DIR, MODEL_DIR, MODEL_DIR_NAME, MODEL_FILE, TOKENS_FILE, MODEL_ARCHIVE_URL, ARCHIVE_INNER_DIR, };
@@ -55,11 +55,20 @@ function writeAuditFile(workspaceDir, data) {
55
55
  // Audit is best-effort — don't fail writes over audit persistence
56
56
  }
57
57
  }
58
+ /** Deduplicate window: ignore a second write to the same path by the same agent within this period. */
59
+ const DEDUP_WINDOW_MS = 60_000;
58
60
  /**
59
61
  * Record an audit entry for a memory write.
62
+ * Skips the entry if an identical path+agent combination was recorded within the dedup window.
60
63
  */
61
64
  export function recordAuditEntry(workspaceDir, entry) {
62
65
  const audit = readAuditFile(workspaceDir);
66
+ // Deduplicate: skip if same path + agent recorded within the last DEDUP_WINDOW_MS
67
+ const dominated = audit.entries.some((e) => e.path === entry.path &&
68
+ e.agentId === entry.agentId &&
69
+ entry.timestamp - e.timestamp < DEDUP_WINDOW_MS);
70
+ if (dominated)
71
+ return;
63
72
  audit.entries.push(entry);
64
73
  // Cap at 500 entries to prevent unbounded growth.
65
74
  if (audit.entries.length > 500) {
@@ -1245,7 +1245,7 @@ export class MemoryIndexManager {
1245
1245
  }
1246
1246
  const action = record ? "updated" : "added";
1247
1247
  log.info(`file ${action} (${this.agentId}): ${entry.path}`);
1248
- if (isAuditablePath(entry.path)) {
1248
+ if (isAuditablePath(entry.path) && record?.hash !== entry.hash) {
1249
1249
  recordAuditEntry(this.workspaceDir, {
1250
1250
  path: entry.path,
1251
1251
  timestamp: Date.now(),
@@ -90,3 +90,13 @@ export function deleteRecordField(id, key) {
90
90
  writeFile(data);
91
91
  return record;
92
92
  }
93
+ export function setRecordName(id, name) {
94
+ const data = readFile();
95
+ const record = data.records[id];
96
+ if (!record)
97
+ return null;
98
+ record.name = name;
99
+ record.updatedAt = new Date().toISOString();
100
+ writeFile(data);
101
+ return record;
102
+ }