listener-ai 1.7.0 → 2.0.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.
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AgentService = exports.READABLE_CONFIG_KEYS = exports.WRITABLE_CONFIG_KEYS = void 0;
37
+ exports.coerceConfigValue = coerceConfigValue;
38
+ exports.isValidFolderName = isValidFolderName;
39
+ exports.describeProposal = describeProposal;
40
+ const genai_1 = require("@google/genai");
41
+ const path = __importStar(require("path"));
42
+ const searchService_1 = require("./searchService");
43
+ const outputService_1 = require("./outputService");
44
+ exports.WRITABLE_CONFIG_KEYS = [
45
+ 'autoMode',
46
+ 'meetingDetection',
47
+ 'displayDetection',
48
+ 'globalShortcut',
49
+ 'maxRecordingMinutes',
50
+ 'recordingReminderMinutes',
51
+ 'minRecordingSeconds',
52
+ ];
53
+ exports.READABLE_CONFIG_KEYS = [
54
+ ...exports.WRITABLE_CONFIG_KEYS,
55
+ 'geminiModel',
56
+ 'geminiFlashModel',
57
+ ];
58
+ function isWritableKey(key) {
59
+ return exports.WRITABLE_CONFIG_KEYS.includes(key);
60
+ }
61
+ function isReadableKey(key) {
62
+ return exports.READABLE_CONFIG_KEYS.includes(key);
63
+ }
64
+ /** Coerce agent-supplied value to the right type per key. */
65
+ function coerceConfigValue(key, raw) {
66
+ switch (key) {
67
+ case 'autoMode':
68
+ case 'meetingDetection':
69
+ case 'displayDetection': {
70
+ if (typeof raw === 'boolean')
71
+ return { ok: true, value: raw };
72
+ if (raw === 'true')
73
+ return { ok: true, value: true };
74
+ if (raw === 'false')
75
+ return { ok: true, value: false };
76
+ return { ok: false, error: `${key} expects a boolean` };
77
+ }
78
+ case 'globalShortcut': {
79
+ if (typeof raw !== 'string' || raw.trim() === '') {
80
+ return { ok: false, error: `${key} expects a non-empty string` };
81
+ }
82
+ return { ok: true, value: raw.trim() };
83
+ }
84
+ case 'maxRecordingMinutes':
85
+ case 'recordingReminderMinutes':
86
+ case 'minRecordingSeconds': {
87
+ const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
88
+ if (!Number.isFinite(n) || n < 0) {
89
+ return { ok: false, error: `${key} expects a non-negative integer` };
90
+ }
91
+ return { ok: true, value: Math.floor(n) };
92
+ }
93
+ }
94
+ }
95
+ function buildTools(scope, hasConfirm) {
96
+ const tools = [];
97
+ if (scope.kind === 'all') {
98
+ tools.push({
99
+ name: 'search_transcriptions',
100
+ description: 'Full-text search across saved meeting transcriptions. Returns top-k hits with title, date, snippet, and folder name. Use this to find meetings relevant to the user question.',
101
+ parameters: {
102
+ type: genai_1.Type.OBJECT,
103
+ properties: {
104
+ query: { type: genai_1.Type.STRING, description: 'Search keywords. Can be Korean or English.' },
105
+ limit: { type: genai_1.Type.INTEGER, description: 'Max hits to return (default 5).' },
106
+ include_transcript: { type: genai_1.Type.BOOLEAN, description: 'Also search the full transcript body (slower). Default false.' },
107
+ },
108
+ required: ['query'],
109
+ },
110
+ });
111
+ tools.push({
112
+ name: 'list_recent_transcriptions',
113
+ description: 'List the most recent saved transcriptions, newest first. Use when the user asks "what did we talk about recently" or "show me yesterday\'s meetings".',
114
+ parameters: {
115
+ type: genai_1.Type.OBJECT,
116
+ properties: {
117
+ limit: { type: genai_1.Type.INTEGER, description: 'Max entries (default 10).' },
118
+ },
119
+ },
120
+ });
121
+ tools.push({
122
+ name: 'get_transcription',
123
+ description: 'Fetch a saved meeting record (summary, key points, action items) by folder name. Pass include_transcript=true only when you need the verbatim transcript body; omit it for summary-level questions to keep the response compact.',
124
+ parameters: {
125
+ type: genai_1.Type.OBJECT,
126
+ properties: {
127
+ folder_name: { type: genai_1.Type.STRING, description: 'The folderName returned by search_transcriptions or list_recent_transcriptions.' },
128
+ include_transcript: { type: genai_1.Type.BOOLEAN, description: 'Include the full transcript body. Default false.' },
129
+ },
130
+ required: ['folder_name'],
131
+ },
132
+ });
133
+ }
134
+ tools.push({
135
+ name: 'get_config',
136
+ description: 'Read a single Listener.AI setting value. Allowed keys: ' + exports.READABLE_CONFIG_KEYS.join(', ') + '. API keys and database IDs are never readable here.',
137
+ parameters: {
138
+ type: genai_1.Type.OBJECT,
139
+ properties: {
140
+ key: { type: genai_1.Type.STRING, description: 'One of: ' + exports.READABLE_CONFIG_KEYS.join(', ') },
141
+ },
142
+ required: ['key'],
143
+ },
144
+ });
145
+ if (hasConfirm) {
146
+ tools.push({
147
+ name: 'set_config',
148
+ description: 'Propose a change to a Listener.AI setting. Requires user confirmation before taking effect. Allowed keys: ' + exports.WRITABLE_CONFIG_KEYS.join(', ') + '. Do NOT try to set API keys, Notion database ID, or other credentials here.',
149
+ parameters: {
150
+ type: genai_1.Type.OBJECT,
151
+ properties: {
152
+ key: { type: genai_1.Type.STRING, description: 'One of: ' + exports.WRITABLE_CONFIG_KEYS.join(', ') },
153
+ value: { type: genai_1.Type.STRING, description: 'The new value. For booleans pass "true"/"false"; for numbers pass the digits as a string; for strings pass the string.' },
154
+ reason: { type: genai_1.Type.STRING, description: 'Short human-readable reason shown to the user in the confirmation prompt.' },
155
+ },
156
+ required: ['key', 'value'],
157
+ },
158
+ });
159
+ }
160
+ return tools;
161
+ }
162
+ function systemInstructionFor(scope, currentTranscriptionTitle) {
163
+ const common = `You are the assistant inside Listener.AI, a Korean-first meeting recorder. Answer concisely in the user's language (default to Korean if the question is in Korean). Ground factual claims in tool results — never invent meeting content. If the user asks for something outside your tools (e.g. sending email, starting a recording), politely say you can't do that yet.`;
164
+ if (scope.kind === 'single') {
165
+ return `${common}\n\nYou are currently focused on a single meeting titled "${currentTranscriptionTitle ?? 'this meeting'}". The full transcription data is already provided in the conversation. Prefer answering directly from that data. Do not call search_transcriptions or list_recent_transcriptions in this mode — they are disabled. You may still read or change app settings via get_config / set_config (set_config always requires user confirmation).`;
166
+ }
167
+ return `${common}\n\nScope: ALL saved meetings. Use search_transcriptions to find relevant meetings (title/summary/key points are searched by default; pass include_transcript=true if keywords are likely only in the transcript body). Use list_recent_transcriptions for "recent/latest" questions. Use get_transcription to read a specific meeting when you have its folder name.`;
168
+ }
169
+ /** Reject folder names that could escape the transcriptions directory. A valid
170
+ * entry produced by `saveTranscription` never contains path separators, so we
171
+ * can keep the guard simple without duplicating the sanitizer. */
172
+ function isValidFolderName(name) {
173
+ if (typeof name !== 'string' || name === '' || name === '.' || name === '..')
174
+ return false;
175
+ if (name.includes('/') || name.includes('\\') || name.includes('\0'))
176
+ return false;
177
+ if (name.startsWith('.'))
178
+ return false;
179
+ return true;
180
+ }
181
+ function buildSinglePrimer(data) {
182
+ const lines = [];
183
+ lines.push(`[Context: meeting "${data.title}"${data.transcribedAt ? ` recorded ${data.transcribedAt.slice(0, 10)}` : ''}]`);
184
+ if (data.summary)
185
+ lines.push(`Summary: ${data.summary}`);
186
+ if (data.keyPoints?.length) {
187
+ lines.push('Key points:');
188
+ for (const p of data.keyPoints)
189
+ lines.push(`- ${p}`);
190
+ }
191
+ if (data.actionItems?.length) {
192
+ lines.push('Action items:');
193
+ for (const a of data.actionItems)
194
+ lines.push(`- ${a}`);
195
+ }
196
+ if (data.transcript) {
197
+ lines.push('Transcript:');
198
+ lines.push(data.transcript);
199
+ }
200
+ return lines.join('\n');
201
+ }
202
+ function historyToContents(history) {
203
+ const out = [];
204
+ for (const m of history) {
205
+ // Model messages replay their full turn cluster (text + function calls +
206
+ // tool responses) so the agent can reason about prior tool use.
207
+ if (m.role === 'model' && m.turns && m.turns.length > 0) {
208
+ out.push(...m.turns);
209
+ continue;
210
+ }
211
+ out.push({ role: m.role, parts: [{ text: m.text }] });
212
+ }
213
+ return out;
214
+ }
215
+ function extractFinalText(parts) {
216
+ if (!parts)
217
+ return '';
218
+ return parts
219
+ .map((p) => (typeof p.text === 'string' ? p.text : ''))
220
+ .filter(Boolean)
221
+ .join('\n')
222
+ .trim();
223
+ }
224
+ class AgentService {
225
+ constructor(opts) {
226
+ this.ai = new genai_1.GoogleGenAI({ apiKey: opts.apiKey });
227
+ this.dataPath = opts.dataPath;
228
+ this.configService = opts.configService;
229
+ this.defaultModel = opts.defaultModel ?? opts.configService.getGeminiFlashModel();
230
+ }
231
+ async run(opts) {
232
+ const model = opts.model ?? this.defaultModel;
233
+ const maxSteps = opts.maxSteps ?? 6;
234
+ const tools = buildTools(opts.scope, !!opts.confirm);
235
+ // Load the single-meeting record once if needed; title + primer derive from it.
236
+ const singleData = opts.scope.kind === 'single' && isValidFolderName(opts.scope.folderName)
237
+ ? (0, outputService_1.readTranscription)(path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), opts.scope.folderName))
238
+ : null;
239
+ const systemInstruction = systemInstructionFor(opts.scope, singleData?.title);
240
+ const history = opts.history ? [...opts.history] : [];
241
+ // For single-meeting scope the primer must precede all prior turns so the
242
+ // model sees the meeting context before its own earlier responses about it.
243
+ const contents = [];
244
+ if (singleData) {
245
+ contents.push({ role: 'user', parts: [{ text: buildSinglePrimer(singleData) }] });
246
+ }
247
+ for (const c of historyToContents(history))
248
+ contents.push(c);
249
+ contents.push({ role: 'user', parts: [{ text: opts.question }] });
250
+ history.push({ role: 'user', text: opts.question });
251
+ // Track turns added from here on so they can be attached to the model
252
+ // message for multi-turn tool memory.
253
+ const modelTurnsStart = contents.length;
254
+ const applied = [];
255
+ let finalAnswer = '';
256
+ for (let step = 0; step < maxSteps; step++) {
257
+ const response = await this.ai.models.generateContent({
258
+ model,
259
+ contents,
260
+ config: {
261
+ systemInstruction,
262
+ temperature: 0.3,
263
+ tools: tools.length > 0 ? [{ functionDeclarations: tools }] : undefined,
264
+ },
265
+ });
266
+ const candidate = response.candidates?.[0];
267
+ const parts = candidate?.content?.parts ?? [];
268
+ const functionCalls = response.functionCalls ?? [];
269
+ // Record model turn verbatim (keeps function call history correct).
270
+ if (candidate?.content) {
271
+ contents.push(candidate.content);
272
+ }
273
+ if (functionCalls.length === 0) {
274
+ finalAnswer = extractFinalText(parts);
275
+ break;
276
+ }
277
+ // Dispatch all tool calls from this turn in parallel. Read-only tools
278
+ // (search/list/get) benefit directly; set_config awaits a user click but
279
+ // that still happens concurrently with the reads rather than after them.
280
+ const results = await Promise.all(functionCalls.map((call) => this.dispatchTool(call, opts, applied)));
281
+ const toolResponseParts = functionCalls.map((call, i) => ({
282
+ functionResponse: {
283
+ id: call.id,
284
+ name: call.name ?? '',
285
+ response: results[i],
286
+ },
287
+ }));
288
+ contents.push({ role: 'user', parts: toolResponseParts });
289
+ }
290
+ if (!finalAnswer) {
291
+ finalAnswer = '(no answer produced within step limit)';
292
+ }
293
+ const modelTurns = contents.slice(modelTurnsStart);
294
+ history.push({ role: 'model', text: finalAnswer, turns: modelTurns });
295
+ return { answer: finalAnswer, appliedActions: applied, history };
296
+ }
297
+ async dispatchTool(call, opts, applied) {
298
+ const args = (call.args ?? {});
299
+ try {
300
+ switch (call.name) {
301
+ case 'search_transcriptions': {
302
+ const query = typeof args.query === 'string' ? args.query : '';
303
+ if (!query.trim())
304
+ return { error: 'query is required' };
305
+ const limit = typeof args.limit === 'number' ? args.limit : 5;
306
+ const includeTranscript = args.include_transcript === true;
307
+ const fields = includeTranscript ? [...searchService_1.ALL_FIELDS] : ['title', 'summary', 'keyPoints', 'actionItems'];
308
+ const hits = (0, searchService_1.searchTranscriptions)(this.dataPath, { query, fields, limit });
309
+ return {
310
+ hits: hits.map((h) => ({
311
+ folder_name: h.entry.folderName,
312
+ title: h.data.title,
313
+ transcribed_at: h.entry.transcribedAt,
314
+ matched_fields: h.matchedFields,
315
+ snippet: h.snippet,
316
+ summary: h.data.summary,
317
+ })),
318
+ };
319
+ }
320
+ case 'list_recent_transcriptions': {
321
+ const limit = typeof args.limit === 'number' ? args.limit : 10;
322
+ const entries = (0, outputService_1.listTranscriptions)(this.dataPath, limit);
323
+ return {
324
+ entries: entries.map((e) => ({
325
+ folder_name: e.folderName,
326
+ title: e.title,
327
+ transcribed_at: e.transcribedAt,
328
+ })),
329
+ };
330
+ }
331
+ case 'get_transcription': {
332
+ const folderName = typeof args.folder_name === 'string' ? args.folder_name : '';
333
+ if (!isValidFolderName(folderName))
334
+ return { error: 'folder_name must be a bare folder name returned by search/list (no slashes, no ..)' };
335
+ const folderPath = path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), folderName);
336
+ const data = (0, outputService_1.readTranscription)(folderPath);
337
+ if (!data)
338
+ return { error: `transcription not found: ${folderName}` };
339
+ const result = {
340
+ title: data.title,
341
+ transcribed_at: data.transcribedAt,
342
+ summary: data.summary,
343
+ key_points: data.keyPoints ?? [],
344
+ action_items: data.actionItems ?? [],
345
+ };
346
+ if (args.include_transcript === true)
347
+ result.transcript = data.transcript;
348
+ return result;
349
+ }
350
+ case 'get_config': {
351
+ const key = typeof args.key === 'string' ? args.key : '';
352
+ if (!isReadableKey(key))
353
+ return { error: `unknown or non-readable key: ${key}` };
354
+ const value = this.configService.getAllConfig()[key];
355
+ return { key, value: value ?? null };
356
+ }
357
+ case 'set_config': {
358
+ if (!opts.confirm)
359
+ return { error: 'set_config not available in this session' };
360
+ const key = typeof args.key === 'string' ? args.key : '';
361
+ if (!isWritableKey(key))
362
+ return { error: `key is not settable via agent: ${key}` };
363
+ const coerced = coerceConfigValue(key, args.value);
364
+ if (!coerced.ok)
365
+ return { error: coerced.error };
366
+ const previousValue = this.configService.getAllConfig()[key];
367
+ const description = describeProposal(key, coerced.value, previousValue, typeof args.reason === 'string' ? args.reason : undefined);
368
+ const approved = await opts.confirm({ kind: 'setConfig', key, value: coerced.value, currentValue: previousValue, description });
369
+ if (!approved) {
370
+ return { approved: false, note: 'User declined the change.' };
371
+ }
372
+ this.configService.updateConfig({ [key]: coerced.value });
373
+ applied.push({ type: 'setConfig', key, value: coerced.value, previousValue });
374
+ return { approved: true, key, value: coerced.value };
375
+ }
376
+ default:
377
+ return { error: `unknown tool: ${call.name}` };
378
+ }
379
+ }
380
+ catch (err) {
381
+ return { error: err instanceof Error ? err.message : String(err) };
382
+ }
383
+ }
384
+ }
385
+ exports.AgentService = AgentService;
386
+ function describeProposal(key, value, current, reason) {
387
+ const currentStr = current === undefined || current === null || current === '' ? '(unset)' : JSON.stringify(current);
388
+ const valueStr = JSON.stringify(value);
389
+ const base = `Change ${key}: ${currentStr} -> ${valueStr}`;
390
+ return reason ? `${base} (${reason})` : base;
391
+ }
package/dist/cli.js CHANGED
@@ -40,6 +40,8 @@ const dataPath_1 = require("./dataPath");
40
40
  const configService_1 = require("./configService");
41
41
  const geminiService_1 = require("./geminiService");
42
42
  const outputService_1 = require("./outputService");
43
+ const searchService_1 = require("./searchService");
44
+ const agentService_1 = require("./agentService");
43
45
  const SUPPORTED_EXTENSIONS = new Set([
44
46
  '.mp3', '.m4a', '.wav', '.ogg', '.flac', '.aac', '.wma', '.opus', '.webm',
45
47
  ]);
@@ -49,6 +51,10 @@ function usage() {
49
51
  ' listener show <ref> Print summary to stdout\n' +
50
52
  ' listener export <ref> [<path>] [--json] [--transcript]\n' +
51
53
  ' Export transcription\n' +
54
+ ' listener search <query> [--limit <n>] [--transcript] [--field <name>]\n' +
55
+ ' Search past transcriptions\n' +
56
+ ' listener ask <question> [--ref <ref>]\n' +
57
+ ' Ask the AI agent about saved meetings or settings\n' +
52
58
  ' listener config list|get|set|path Manage configuration\n' +
53
59
  '\n' +
54
60
  '<ref> is a number from `listener list` or a folder name.\n' +
@@ -57,7 +63,8 @@ function usage() {
57
63
  ' --output <dir> Parent directory for the output folder\n' +
58
64
  ' --limit <n> Max results (0 = all, default: 20)\n' +
59
65
  ' --json Export as JSON instead of markdown\n' +
60
- ' --transcript Include full transcript in export output\n' +
66
+ ' --transcript Include transcript body (export: append; search: widen scope)\n' +
67
+ ' --field <name> Restrict search to one of: title, summary, keyPoints, actionItems, transcript, all\n' +
61
68
  ' --help Show this help message\n');
62
69
  process.exit(1);
63
70
  }
@@ -303,6 +310,134 @@ function handleExport(args) {
303
310
  }
304
311
  }
305
312
  }
313
+ function handleSearch(args) {
314
+ const VALID_FIELDS = [...searchService_1.ALL_FIELDS, 'all'];
315
+ let query;
316
+ let limit = 20;
317
+ let includeTranscript = false;
318
+ let field;
319
+ for (let i = 0; i < args.length; i++) {
320
+ const a = args[i];
321
+ if (a === '--limit' && i + 1 < args.length) {
322
+ const n = parseInt(args[++i], 10);
323
+ if (isNaN(n) || n < 0) {
324
+ process.stderr.write('Error: --limit must be a non-negative integer\n');
325
+ process.exit(1);
326
+ }
327
+ limit = n;
328
+ continue;
329
+ }
330
+ if (a === '--transcript') {
331
+ includeTranscript = true;
332
+ continue;
333
+ }
334
+ if (a === '--field' && i + 1 < args.length) {
335
+ const v = args[++i];
336
+ if (!VALID_FIELDS.includes(v)) {
337
+ process.stderr.write(`Error: --field must be one of: ${VALID_FIELDS.join(', ')}\n`);
338
+ process.exit(1);
339
+ }
340
+ field = v;
341
+ continue;
342
+ }
343
+ if (a.startsWith('-')) {
344
+ process.stderr.write(`Error: Unknown option: ${a}\n`);
345
+ process.exit(1);
346
+ }
347
+ if (!query) {
348
+ query = a;
349
+ continue;
350
+ }
351
+ process.stderr.write(`Error: Unexpected argument: ${a} (quote multi-word queries)\n`);
352
+ process.exit(1);
353
+ }
354
+ if (!query || query.trim() === '') {
355
+ process.stderr.write('Error: Missing query. Usage: listener search <query> [--limit <n>] [--transcript] [--field <name>]\n');
356
+ process.exit(1);
357
+ }
358
+ const dataPath = (0, dataPath_1.getDataPath)();
359
+ const fields = (0, searchService_1.resolveFields)({ field, includeTranscript });
360
+ const hits = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
361
+ if (hits.length === 0) {
362
+ process.stderr.write('No results.\n');
363
+ return;
364
+ }
365
+ for (const hit of hits) {
366
+ const date = hit.entry.transcribedAt ? hit.entry.transcribedAt.slice(0, 10) : ' ';
367
+ const title = hit.data.title.length > 60 ? hit.data.title.slice(0, 57) + '...' : hit.data.title;
368
+ process.stdout.write(`${date} ${title}\n`);
369
+ process.stdout.write(` ref: ${hit.entry.folderName}\n`);
370
+ process.stdout.write(` matches: ${hit.matchedFields.join(', ')}\n`);
371
+ if (hit.snippet) {
372
+ process.stdout.write(` ${hit.snippet}\n`);
373
+ }
374
+ process.stdout.write('\n');
375
+ }
376
+ }
377
+ async function promptYesNo(message) {
378
+ return new Promise((resolve) => {
379
+ process.stderr.write(`${message} [y/N] `);
380
+ const stdin = process.stdin;
381
+ const onData = (chunk) => {
382
+ stdin.off('data', onData);
383
+ stdin.pause();
384
+ const answer = chunk.toString('utf-8').trim().toLowerCase();
385
+ resolve(answer === 'y' || answer === 'yes');
386
+ };
387
+ stdin.resume();
388
+ stdin.on('data', onData);
389
+ });
390
+ }
391
+ async function handleAsk(args) {
392
+ let question;
393
+ let ref;
394
+ for (let i = 0; i < args.length; i++) {
395
+ const a = args[i];
396
+ if (a === '--ref' && i + 1 < args.length) {
397
+ ref = args[++i];
398
+ continue;
399
+ }
400
+ if (a.startsWith('-')) {
401
+ process.stderr.write(`Error: Unknown option: ${a}\n`);
402
+ process.exit(1);
403
+ }
404
+ if (!question) {
405
+ question = a;
406
+ continue;
407
+ }
408
+ process.stderr.write(`Error: Unexpected argument: ${a} (quote multi-word questions)\n`);
409
+ process.exit(1);
410
+ }
411
+ if (!question || question.trim() === '') {
412
+ process.stderr.write('Error: Missing question. Usage: listener ask <question> [--ref <ref>]\n');
413
+ process.exit(1);
414
+ }
415
+ const dataPath = (0, dataPath_1.getDataPath)();
416
+ const config = new configService_1.ConfigService(dataPath);
417
+ const apiKey = config.getGeminiApiKey();
418
+ if (!apiKey) {
419
+ process.stderr.write('Error: Gemini API key not found. Set GEMINI_API_KEY env var or configure via the Listener.AI app.\n');
420
+ process.exit(1);
421
+ }
422
+ let scope = { kind: 'all' };
423
+ if (ref) {
424
+ const folderPath = resolveRef(ref, dataPath);
425
+ scope = { kind: 'single', folderName: path.basename(folderPath) };
426
+ }
427
+ const agent = new agentService_1.AgentService({ apiKey, dataPath, configService: config });
428
+ const confirm = async (proposal) => {
429
+ process.stderr.write('\n');
430
+ return promptYesNo(`Proposed change -> ${proposal.description}\nApply?`);
431
+ };
432
+ const result = await agent.run({ question, scope, confirm });
433
+ process.stdout.write(`${result.answer}\n`);
434
+ if (result.appliedActions.length > 0) {
435
+ process.stderr.write('\nApplied:\n');
436
+ for (const action of result.appliedActions) {
437
+ process.stderr.write(` ${action.key}: ${JSON.stringify(action.previousValue ?? null)} -> ${JSON.stringify(action.value)}\n`);
438
+ }
439
+ }
440
+ }
306
441
  async function main() {
307
442
  const args = process.argv.slice(2);
308
443
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
@@ -324,6 +459,14 @@ async function main() {
324
459
  handleExport(args.slice(1));
325
460
  return;
326
461
  }
462
+ if (args[0] === 'search') {
463
+ handleSearch(args.slice(1));
464
+ return;
465
+ }
466
+ if (args[0] === 'ask') {
467
+ await handleAsk(args.slice(1));
468
+ return;
469
+ }
327
470
  // Parse arguments
328
471
  let filePath;
329
472
  let outputDir;
package/dist/main.js CHANGED
@@ -50,6 +50,8 @@ const autoUpdaterService_1 = require("./services/autoUpdaterService");
50
50
  const notificationService_1 = require("./services/notificationService");
51
51
  const releaseNotesService_1 = require("./services/releaseNotesService");
52
52
  const outputService_1 = require("./outputService");
53
+ const searchService_1 = require("./searchService");
54
+ const agentService_1 = require("./agentService");
53
55
  global.isQuitting = false;
54
56
  let mainWindow = null;
55
57
  const audioRecorder = new simpleAudioRecorder_1.SimpleAudioRecorder();
@@ -62,6 +64,35 @@ const displayDetector = new displayDetectorService_1.DisplayDetectorService();
62
64
  let meetingAutoStartedRecording = false;
63
65
  let geminiService = null;
64
66
  let notionService = null;
67
+ let agentService = null;
68
+ function getAgentService() {
69
+ if (agentService)
70
+ return agentService;
71
+ const apiKey = configService.getGeminiApiKey();
72
+ if (!apiKey)
73
+ return null;
74
+ agentService = new agentService_1.AgentService({
75
+ apiKey,
76
+ dataPath: electron_1.app.getPath('userData'),
77
+ configService,
78
+ });
79
+ return agentService;
80
+ }
81
+ // Pending agent confirmation resolvers keyed by request id. The renderer responds
82
+ // via the 'agent-confirm-response' IPC and we resolve the promise the agent is awaiting.
83
+ // If the window goes away (reload, crash, close) before the user answers, we
84
+ // auto-reject so the awaiting agent-chat call can unwind instead of hanging.
85
+ const pendingConfirms = new Map();
86
+ let confirmIdCounter = 0;
87
+ function rejectAllPendingConfirms() {
88
+ for (const resolver of pendingConfirms.values()) {
89
+ try {
90
+ resolver(false);
91
+ }
92
+ catch { /* ignore */ }
93
+ }
94
+ pendingConfirms.clear();
95
+ }
65
96
  let recordingMaxTimer = null;
66
97
  let recordingReminderTimer = null;
67
98
  function createGeminiService() {
@@ -177,6 +208,8 @@ function createWindow() {
177
208
  mainWindow = new electron_1.BrowserWindow({
178
209
  width: 1000,
179
210
  height: 700,
211
+ minWidth: 480,
212
+ minHeight: 520,
180
213
  webPreferences: {
181
214
  nodeIntegration: false,
182
215
  contextIsolation: true,
@@ -214,8 +247,18 @@ function createWindow() {
214
247
  }
215
248
  });
216
249
  mainWindow.on('closed', () => {
250
+ rejectAllPendingConfirms();
217
251
  mainWindow = null;
218
252
  });
253
+ // Reload or crash drops the renderer that would answer a confirm prompt;
254
+ // unblock any waiting agent call so it returns cleanly instead of hanging.
255
+ mainWindow.webContents.on('did-start-navigation', (_e, _url, isInPlace, isMainFrame) => {
256
+ if (isMainFrame && !isInPlace)
257
+ rejectAllPendingConfirms();
258
+ });
259
+ mainWindow.webContents.on('render-process-gone', () => {
260
+ rejectAllPendingConfirms();
261
+ });
219
262
  }
220
263
  electron_1.app.whenReady().then(() => {
221
264
  // Initialize auto-updater
@@ -525,30 +568,42 @@ electron_1.ipcMain.handle('stop-recording', async () => {
525
568
  }
526
569
  });
527
570
  // Configuration handlers
571
+ // Apply runtime side effects for changed config keys (shortcut re-registration,
572
+ // detector on/off, service re-creation). Called from both the GUI save-config
573
+ // IPC and the agent-chat flow when set_config mutations land.
574
+ function applyConfigSideEffects(changed) {
575
+ if (changed.knownWords !== undefined || changed.geminiApiKey !== undefined || changed.geminiModel !== undefined || changed.geminiFlashModel !== undefined) {
576
+ geminiService = createGeminiService();
577
+ agentService = null;
578
+ }
579
+ if (changed.globalShortcut !== undefined) {
580
+ registerGlobalShortcut();
581
+ }
582
+ if (changed.meetingDetection !== undefined) {
583
+ meetingDetector.setEnabled(changed.meetingDetection);
584
+ }
585
+ if (changed.displayDetection !== undefined) {
586
+ displayDetector.setEnabled(changed.displayDetection);
587
+ }
588
+ if (changed.notionApiKey !== undefined || changed.notionDatabaseId !== undefined) {
589
+ const apiKey = configService.getNotionApiKey();
590
+ const databaseId = configService.getNotionDatabaseId();
591
+ if (apiKey && databaseId) {
592
+ notionService = new notionService_1.NotionService({ apiKey, databaseId });
593
+ }
594
+ }
595
+ }
596
+ // Tell the renderer the config has changed out-of-band so it can re-read and
597
+ // re-render its UI state (toggle checkboxes etc.). Used by the agent flow.
598
+ function broadcastConfigChanged() {
599
+ if (mainWindow && !mainWindow.isDestroyed()) {
600
+ mainWindow.webContents.send('config-changed', configService.getAllConfig());
601
+ }
602
+ }
528
603
  electron_1.ipcMain.handle('save-config', async (_, config) => {
529
604
  try {
530
605
  configService.updateConfig(config);
531
- // Recreate GeminiService if any relevant setting changed
532
- if (config.knownWords !== undefined || config.geminiApiKey !== undefined || config.geminiModel !== undefined || config.geminiFlashModel !== undefined) {
533
- geminiService = createGeminiService();
534
- }
535
- if (config.globalShortcut !== undefined) {
536
- registerGlobalShortcut();
537
- }
538
- if (config.meetingDetection !== undefined) {
539
- meetingDetector.setEnabled(config.meetingDetection);
540
- }
541
- if (config.displayDetection !== undefined) {
542
- displayDetector.setEnabled(config.displayDetection);
543
- }
544
- // Recreate Notion service if either field changed
545
- if (config.notionApiKey !== undefined || config.notionDatabaseId !== undefined) {
546
- const apiKey = configService.getNotionApiKey();
547
- const databaseId = configService.getNotionDatabaseId();
548
- if (apiKey && databaseId) {
549
- notionService = new notionService_1.NotionService({ apiKey, databaseId });
550
- }
551
- }
606
+ applyConfigSideEffects(config);
552
607
  return {
553
608
  success: true,
554
609
  configPath: electron_1.app.getPath('userData')
@@ -741,11 +796,13 @@ electron_1.ipcMain.handle('get-metadata', async (_, filePath) => {
741
796
  success: true,
742
797
  data: {
743
798
  ...metadata,
799
+ folderName: path.basename(metadata.transcriptionPath),
744
800
  transcript: transcription.transcript,
745
801
  summary: transcription.summary,
746
802
  keyPoints: transcription.keyPoints,
747
803
  actionItems: transcription.actionItems,
748
804
  customFields: transcription.customFields ?? metadata.customFields,
805
+ emoji: transcription.emoji,
749
806
  }
750
807
  };
751
808
  }
@@ -777,6 +834,47 @@ electron_1.ipcMain.handle('open-recordings-folder', async () => {
777
834
  // Open the folder in the system file explorer
778
835
  electron_1.shell.openPath(recordingsPath);
779
836
  });
837
+ // Search past transcriptions
838
+ electron_1.ipcMain.handle('search-transcriptions', async (_, opts) => {
839
+ try {
840
+ const query = (opts?.query ?? '').trim();
841
+ if (!query)
842
+ return { success: true, hits: [] };
843
+ // Validate fields against the known whitelist; drop anything else. Unvalidated input
844
+ // (e.g. a stringified 'title') would be passed to `new Set(...)` and silently match nothing.
845
+ const requested = Array.isArray(opts.fields) ? opts.fields : [];
846
+ const filtered = requested.filter((f) => searchService_1.ALL_FIELDS.includes(f));
847
+ const fields = filtered.length > 0 ? filtered : searchService_1.ALL_FIELDS;
848
+ const limit = Number.isFinite(opts.limit) && opts.limit >= 0 ? opts.limit : 20;
849
+ const dataPath = electron_1.app.getPath('userData');
850
+ const raw = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
851
+ const hits = raw.map((h) => ({
852
+ folderName: h.entry.folderName,
853
+ folderPath: h.entry.folderPath,
854
+ transcribedAt: h.entry.transcribedAt,
855
+ title: h.data.title,
856
+ audioFilePath: h.data.audioFilePath ?? '',
857
+ score: h.score,
858
+ matchedFields: h.matchedFields,
859
+ snippet: h.snippet,
860
+ snippetField: h.snippetField ?? null,
861
+ data: {
862
+ title: h.data.title,
863
+ suggestedTitle: h.data.suggestedTitle,
864
+ summary: h.data.summary,
865
+ transcript: h.data.transcript,
866
+ keyPoints: h.data.keyPoints ?? [],
867
+ actionItems: h.data.actionItems ?? [],
868
+ customFields: h.data.customFields ?? {},
869
+ emoji: h.data.emoji,
870
+ },
871
+ }));
872
+ return { success: true, hits };
873
+ }
874
+ catch (error) {
875
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
876
+ }
877
+ });
780
878
  // Get list of recordings
781
879
  electron_1.ipcMain.handle('get-recordings', async () => {
782
880
  try {
@@ -833,5 +931,71 @@ electron_1.ipcMain.handle('open-microphone-settings', async () => {
833
931
  }
834
932
  // For Linux, there's no standard way to open microphone settings
835
933
  });
836
- // Register file handler service
934
+ // Agent chat: blocks until the agent produces a final answer. During the run the
935
+ // main process may ask the renderer to confirm a config change via the
936
+ // 'agent-confirm-request' event; the renderer answers with 'agent-confirm-response'.
937
+ electron_1.ipcMain.handle('agent-chat', async (_event, opts) => {
938
+ try {
939
+ const agent = getAgentService();
940
+ if (!agent) {
941
+ return { success: false, error: 'Gemini API key not configured.' };
942
+ }
943
+ const question = (opts?.question ?? '').trim();
944
+ if (!question)
945
+ return { success: false, error: 'Empty question.' };
946
+ const scope = opts?.scope?.kind === 'single' && typeof opts.scope.folderName === 'string'
947
+ ? { kind: 'single', folderName: opts.scope.folderName }
948
+ : { kind: 'all' };
949
+ const confirm = async (proposal) => {
950
+ if (!mainWindow || mainWindow.isDestroyed())
951
+ return false;
952
+ const id = `cfm_${++confirmIdCounter}`;
953
+ const approval = new Promise((resolve) => {
954
+ pendingConfirms.set(id, resolve);
955
+ });
956
+ mainWindow.webContents.send('agent-confirm-request', { id, proposal });
957
+ return approval;
958
+ };
959
+ const result = await agent.run({
960
+ question,
961
+ history: Array.isArray(opts?.history) ? opts.history : [],
962
+ scope,
963
+ confirm,
964
+ });
965
+ // The agent only mutates via set_config; replay each applied write into the
966
+ // runtime side-effect pipeline so shortcut/detector state matches the disk
967
+ // config, then push a 'config-changed' event so the renderer can re-render
968
+ // its toggle checkboxes without waiting for a full reload.
969
+ if (result.appliedActions.length > 0) {
970
+ const changed = {};
971
+ for (const action of result.appliedActions) {
972
+ if (action.type === 'setConfig') {
973
+ changed[action.key] = action.value;
974
+ }
975
+ }
976
+ applyConfigSideEffects(changed);
977
+ broadcastConfigChanged();
978
+ }
979
+ return { success: true, result };
980
+ }
981
+ catch (error) {
982
+ console.error('agent-chat failed:', error);
983
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
984
+ }
985
+ });
986
+ electron_1.ipcMain.handle('agent-confirm-response', async (_, payload) => {
987
+ const resolver = pendingConfirms.get(payload.id);
988
+ if (resolver) {
989
+ pendingConfirms.delete(payload.id);
990
+ resolver(!!payload.approved);
991
+ }
992
+ return { success: true };
993
+ });
994
+ // Renderer-triggered bail-out: if the user clicks "Stop" on a pending chat
995
+ // bubble while a set_config confirm is outstanding, reject it so the awaiting
996
+ // agent call unwinds and the input unlocks. No-op when nothing is pending.
997
+ electron_1.ipcMain.handle('agent-cancel-pending', async () => {
998
+ rejectAllPendingConfirms();
999
+ return { success: true };
1000
+ });
837
1001
  fileHandlerService.registerHandlers();
@@ -134,6 +134,9 @@ function buildFrontmatter(meta) {
134
134
  if (meta.audioFilePath) {
135
135
  lines.push(`audioFilePath: ${yamlQuote(meta.audioFilePath)}`);
136
136
  }
137
+ if (meta.emoji) {
138
+ lines.push(`emoji: ${yamlQuote(meta.emoji)}`);
139
+ }
137
140
  lines.push('---');
138
141
  return lines.join('\n');
139
142
  }
@@ -224,6 +227,7 @@ function saveTranscription(opts) {
224
227
  customFields: opts.result.customFields,
225
228
  audioFilePath: opts.audioFilePath,
226
229
  transcribedAt,
230
+ emoji: opts.result.emoji,
227
231
  });
228
232
  const summaryBody = formatSummary(opts.result, opts.title);
229
233
  fs.writeFileSync(path.join(folderPath, 'summary.md'), `${frontmatter}\n\n${summaryBody}`, 'utf-8');
@@ -317,6 +321,7 @@ function readTranscription(folderPath) {
317
321
  customFields,
318
322
  audioFilePath: meta.audioFilePath,
319
323
  transcribedAt: meta.transcribedAt,
324
+ emoji: meta.emoji,
320
325
  };
321
326
  }
322
327
  catch {
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ALL_FIELDS = exports.DEFAULT_FIELDS = exports.FIELD_WEIGHTS = void 0;
4
+ exports.makeSnippet = makeSnippet;
5
+ exports.scoreRecord = scoreRecord;
6
+ exports.resolveFields = resolveFields;
7
+ exports.searchTranscriptions = searchTranscriptions;
8
+ const outputService_1 = require("./outputService");
9
+ exports.FIELD_WEIGHTS = {
10
+ title: 10,
11
+ summary: 5,
12
+ keyPoints: 3,
13
+ actionItems: 3,
14
+ transcript: 1,
15
+ };
16
+ exports.DEFAULT_FIELDS = ['title', 'summary', 'keyPoints', 'actionItems'];
17
+ exports.ALL_FIELDS = ['title', 'summary', 'keyPoints', 'actionItems', 'transcript'];
18
+ /** Extract a ~width-char snippet centered on the first occurrence of needle. */
19
+ function makeSnippet(text, needle, width = 80, matchIndex) {
20
+ if (!text || !needle)
21
+ return '';
22
+ const idx = matchIndex ?? text.toLowerCase().indexOf(needle.toLowerCase());
23
+ if (idx === -1)
24
+ return '';
25
+ const half = Math.floor(width / 2);
26
+ const start = Math.max(0, idx - half);
27
+ const end = Math.min(text.length, idx + needle.length + half);
28
+ let snippet = text.slice(start, end).replace(/\s+/g, ' ').trim();
29
+ if (start > 0)
30
+ snippet = '...' + snippet;
31
+ if (end < text.length)
32
+ snippet = snippet + '...';
33
+ return snippet;
34
+ }
35
+ /** Returns the match index in haystack (lowercased) for a pre-lowercased needle, or -1. */
36
+ function findIndex(haystack, needleLower) {
37
+ if (!haystack)
38
+ return -1;
39
+ return haystack.toLowerCase().indexOf(needleLower);
40
+ }
41
+ function firstArrayHit(haystack, needleLower) {
42
+ if (!haystack)
43
+ return undefined;
44
+ for (const s of haystack) {
45
+ const idx = s.toLowerCase().indexOf(needleLower);
46
+ if (idx !== -1)
47
+ return { text: s, index: idx };
48
+ }
49
+ return undefined;
50
+ }
51
+ /**
52
+ * Score one record against a pre-lowercased needle and pre-built scope Set.
53
+ * The bulk-search path hoists these out of the per-entry loop; `scoreRecord`
54
+ * below is a thin convenience wrapper for direct callers (tests).
55
+ */
56
+ function scoreRecordPrepared(entry, data, needle, scope) {
57
+ if (!needle)
58
+ return null;
59
+ let score = 0;
60
+ const matched = [];
61
+ let snippetSource = '';
62
+ let snippetIndex = -1;
63
+ let snippetField;
64
+ const setSnippet = (field, source, index) => {
65
+ if (!snippetField) {
66
+ snippetField = field;
67
+ snippetSource = source;
68
+ snippetIndex = index;
69
+ }
70
+ };
71
+ if (scope.has('title') && findIndex(data.title, needle) !== -1) {
72
+ score += exports.FIELD_WEIGHTS.title;
73
+ matched.push('title');
74
+ }
75
+ {
76
+ const idx = scope.has('summary') ? findIndex(data.summary, needle) : -1;
77
+ if (idx !== -1) {
78
+ score += exports.FIELD_WEIGHTS.summary;
79
+ matched.push('summary');
80
+ setSnippet('summary', data.summary, idx);
81
+ }
82
+ }
83
+ if (scope.has('keyPoints')) {
84
+ const hit = firstArrayHit(data.keyPoints, needle);
85
+ if (hit) {
86
+ score += exports.FIELD_WEIGHTS.keyPoints;
87
+ matched.push('keyPoints');
88
+ setSnippet('keyPoints', hit.text, hit.index);
89
+ }
90
+ }
91
+ if (scope.has('actionItems')) {
92
+ const hit = firstArrayHit(data.actionItems, needle);
93
+ if (hit) {
94
+ score += exports.FIELD_WEIGHTS.actionItems;
95
+ matched.push('actionItems');
96
+ setSnippet('actionItems', hit.text, hit.index);
97
+ }
98
+ }
99
+ {
100
+ const idx = scope.has('transcript') ? findIndex(data.transcript, needle) : -1;
101
+ if (idx !== -1) {
102
+ score += exports.FIELD_WEIGHTS.transcript;
103
+ matched.push('transcript');
104
+ setSnippet('transcript', data.transcript, idx);
105
+ }
106
+ }
107
+ if (score === 0)
108
+ return null;
109
+ const snippet = snippetSource ? makeSnippet(snippetSource, needle, 80, snippetIndex) : '';
110
+ return { entry, data, score, matchedFields: matched, snippet, snippetField };
111
+ }
112
+ /** Convenience wrapper: lowercases the query and builds the scope Set per call. */
113
+ function scoreRecord(entry, data, query, fields) {
114
+ return scoreRecordPrepared(entry, data, query.toLowerCase(), new Set(fields));
115
+ }
116
+ /** Resolve which fields to search based on CLI flags. */
117
+ function resolveFields(opts) {
118
+ if (opts.field === 'all')
119
+ return [...exports.ALL_FIELDS];
120
+ if (opts.field)
121
+ return [opts.field];
122
+ return opts.includeTranscript ? [...exports.ALL_FIELDS] : [...exports.DEFAULT_FIELDS];
123
+ }
124
+ /** Run a search against the local transcriptions archive. */
125
+ function searchTranscriptions(dataPath, opts) {
126
+ const entries = (0, outputService_1.listTranscriptions)(dataPath, 0);
127
+ const fields = opts.fields ?? exports.DEFAULT_FIELDS;
128
+ const needle = opts.query.toLowerCase();
129
+ const scope = new Set(fields);
130
+ const hits = [];
131
+ for (const entry of entries) {
132
+ const data = (0, outputService_1.readTranscription)(entry.folderPath);
133
+ if (!data)
134
+ continue;
135
+ const hit = scoreRecordPrepared(entry, data, needle, scope);
136
+ if (hit)
137
+ hits.push(hit);
138
+ }
139
+ hits.sort((a, b) => {
140
+ if (b.score !== a.score)
141
+ return b.score - a.score;
142
+ return (b.entry.transcribedAt || '').localeCompare(a.entry.transcribedAt || '');
143
+ });
144
+ const limit = opts.limit;
145
+ if (limit && limit > 0)
146
+ return hits.slice(0, limit);
147
+ return hits; // limit=0 or undefined → return all
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listener-ai",
3
- "version": "1.7.0",
3
+ "version": "2.0.0",
4
4
  "description": "A lightweight desktop application for recording and transcribing meetings with AI-powered notes.",
5
5
  "main": "dist/main.js",
6
6
  "bin": {
@@ -12,12 +12,14 @@
12
12
  "dist/configService.js",
13
13
  "dist/geminiService.js",
14
14
  "dist/outputService.js",
15
+ "dist/searchService.js",
16
+ "dist/agentService.js",
15
17
  "dist/services/ffmpegManager.js"
16
18
  ],
17
19
  "scripts": {
18
20
  "start": "pnpm run build && electron .",
19
21
  "build": "tsc",
20
- "test": "tsc && node --test dist/meetingDetectorService.test.js",
22
+ "test": "tsc && node --test dist/meetingDetectorService.test.js dist/searchService.test.js dist/agentService.test.js",
21
23
  "dist": "pnpm run build && electron-builder",
22
24
  "dist:mac": "pnpm run build && electron-builder --mac",
23
25
  "dist:mac-x64": "pnpm run build && electron-builder --mac --x64",