listener-ai 1.6.3 → 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.
- package/dist/agentService.js +391 -0
- package/dist/cli.js +144 -1
- package/dist/main.js +186 -22
- package/dist/outputService.js +5 -0
- package/dist/searchService.js +148 -0
- package/package.json +5 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
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();
|
package/dist/outputService.js
CHANGED
|
@@ -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": "
|
|
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,11 +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",
|
|
22
|
+
"test": "tsc && node --test dist/meetingDetectorService.test.js dist/searchService.test.js dist/agentService.test.js",
|
|
20
23
|
"dist": "pnpm run build && electron-builder",
|
|
21
24
|
"dist:mac": "pnpm run build && electron-builder --mac",
|
|
22
25
|
"dist:mac-x64": "pnpm run build && electron-builder --mac --x64",
|
|
@@ -91,6 +94,7 @@
|
|
|
91
94
|
"!src/**/*",
|
|
92
95
|
"!.git/**/*",
|
|
93
96
|
"!**/*.ts",
|
|
97
|
+
"!**/*.test.js",
|
|
94
98
|
"!**/*.map",
|
|
95
99
|
"!.gitignore",
|
|
96
100
|
"!.eslintrc",
|