listener-ai 2.5.0 → 2.6.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/README.md +11 -1
- package/dist/cli.js +291 -62
- package/dist/configService.js +4 -0
- package/dist/geminiService.js +163 -81
- package/dist/main.js +89 -20
- package/dist/outputService.js +132 -2
- package/package.json +9 -7
package/dist/outputService.js
CHANGED
|
@@ -33,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.formatOffsetTimestamp = formatOffsetTimestamp;
|
|
36
37
|
exports.sanitizeForPath = sanitizeForPath;
|
|
37
38
|
exports.formatTimestamp = formatTimestamp;
|
|
38
39
|
exports.camelToLabel = camelToLabel;
|
|
39
40
|
exports.formatSummary = formatSummary;
|
|
40
41
|
exports.formatTranscript = formatTranscript;
|
|
41
42
|
exports.parseFrontmatter = parseFrontmatter;
|
|
43
|
+
exports.parseLiveNotesField = parseLiveNotesField;
|
|
44
|
+
exports.parseHighlightsField = parseHighlightsField;
|
|
42
45
|
exports.getTranscriptionsDir = getTranscriptionsDir;
|
|
43
46
|
exports.saveTranscription = saveTranscription;
|
|
44
47
|
exports.listTranscriptions = listTranscriptions;
|
|
@@ -46,6 +49,22 @@ exports.readTranscription = readTranscription;
|
|
|
46
49
|
exports.updateTranscriptionStatus = updateTranscriptionStatus;
|
|
47
50
|
const fs = __importStar(require("fs"));
|
|
48
51
|
const path = __importStar(require("path"));
|
|
52
|
+
/**
|
|
53
|
+
* Format a millisecond offset as `mm:ss` (or `hh:mm:ss` when the offset crosses
|
|
54
|
+
* one hour) — used by every Highlights-rendering sink (summary.md, Notion,
|
|
55
|
+
* modal, CLI) and by the Gemini prompt so the LLM sees the same coordinate
|
|
56
|
+
* system as the saved output.
|
|
57
|
+
*/
|
|
58
|
+
function formatOffsetTimestamp(offsetMs) {
|
|
59
|
+
const totalSeconds = Math.max(0, Math.floor(offsetMs / 1000));
|
|
60
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
61
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
62
|
+
const seconds = totalSeconds % 60;
|
|
63
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
64
|
+
return hours > 0
|
|
65
|
+
? `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
|
66
|
+
: `${pad(minutes)}:${pad(seconds)}`;
|
|
67
|
+
}
|
|
49
68
|
function sanitizeForPath(name) {
|
|
50
69
|
return name
|
|
51
70
|
.replace(/[\/\\:*?"<>|]/g, '_')
|
|
@@ -69,7 +88,7 @@ function camelToLabel(key) {
|
|
|
69
88
|
.replace(/^./, (s) => s.toUpperCase())
|
|
70
89
|
.trim();
|
|
71
90
|
}
|
|
72
|
-
function formatSummary(result, title, mergedFrom) {
|
|
91
|
+
function formatSummary(result, title, mergedFrom, liveNotes, highlights) {
|
|
73
92
|
const lines = [];
|
|
74
93
|
lines.push(`# ${title}\n`);
|
|
75
94
|
if (mergedFrom?.length) {
|
|
@@ -98,6 +117,37 @@ function formatSummary(result, title, mergedFrom) {
|
|
|
98
117
|
}
|
|
99
118
|
lines.push('');
|
|
100
119
|
}
|
|
120
|
+
// Prefer the AI-enriched "highlights" view when present -- it carries the
|
|
121
|
+
// same user notes plus per-moment subtitle/bullets. Fall back to the bare
|
|
122
|
+
// bullet list of liveNotes when Gemini didn't produce highlights.
|
|
123
|
+
const enriched = highlights && highlights.length > 0 ? highlights : null;
|
|
124
|
+
if (enriched || liveNotes?.length) {
|
|
125
|
+
lines.push('## 🗒️ Highlights\n');
|
|
126
|
+
if (enriched) {
|
|
127
|
+
for (const h of enriched) {
|
|
128
|
+
const ts = formatOffsetTimestamp(h.offsetMs);
|
|
129
|
+
const title = (h.userText ?? '').trim();
|
|
130
|
+
lines.push(title ? `### [${ts}] ${title}` : `### [${ts}] 🏴`);
|
|
131
|
+
if (h.subtitle?.trim()) {
|
|
132
|
+
lines.push(`*${h.subtitle.trim()}*`);
|
|
133
|
+
}
|
|
134
|
+
if (h.bullets?.length) {
|
|
135
|
+
lines.push('');
|
|
136
|
+
for (const b of h.bullets)
|
|
137
|
+
lines.push(`- ${b}`);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
for (const note of liveNotes) {
|
|
144
|
+
const ts = formatOffsetTimestamp(note.offsetMs);
|
|
145
|
+
const text = note.text?.trim();
|
|
146
|
+
lines.push(text ? `- [${ts}] ${text}` : `- [${ts}] 🏴`);
|
|
147
|
+
}
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
101
151
|
if (result.customFields) {
|
|
102
152
|
for (const [key, value] of Object.entries(result.customFields)) {
|
|
103
153
|
const label = camelToLabel(key);
|
|
@@ -157,6 +207,12 @@ function buildFrontmatter(meta) {
|
|
|
157
207
|
for (const src of meta.mergedFrom)
|
|
158
208
|
lines.push(` - ${yamlQuote(src)}`);
|
|
159
209
|
}
|
|
210
|
+
if (meta.liveNotes?.length) {
|
|
211
|
+
lines.push(`liveNotes: ${yamlQuote(JSON.stringify(meta.liveNotes))}`);
|
|
212
|
+
}
|
|
213
|
+
if (meta.highlights?.length) {
|
|
214
|
+
lines.push(`highlights: ${yamlQuote(JSON.stringify(meta.highlights))}`);
|
|
215
|
+
}
|
|
160
216
|
if (meta.notionPageUrl) {
|
|
161
217
|
lines.push(`notionPageUrl: ${yamlQuote(meta.notionPageUrl)}`);
|
|
162
218
|
}
|
|
@@ -222,6 +278,66 @@ function parseFrontmatter(content) {
|
|
|
222
278
|
}
|
|
223
279
|
return { meta, body };
|
|
224
280
|
}
|
|
281
|
+
function parseLiveNotesField(raw) {
|
|
282
|
+
if (!raw)
|
|
283
|
+
return undefined;
|
|
284
|
+
try {
|
|
285
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
286
|
+
if (!Array.isArray(parsed))
|
|
287
|
+
return undefined;
|
|
288
|
+
const notes = [];
|
|
289
|
+
for (const item of parsed) {
|
|
290
|
+
if (!item || typeof item !== 'object')
|
|
291
|
+
continue;
|
|
292
|
+
const offsetMs = Number(item.offsetMs);
|
|
293
|
+
const text = item.text;
|
|
294
|
+
if (!Number.isFinite(offsetMs))
|
|
295
|
+
continue;
|
|
296
|
+
notes.push({
|
|
297
|
+
offsetMs: Math.max(0, Math.floor(offsetMs)),
|
|
298
|
+
text: typeof text === 'string' ? text : '',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return notes.length > 0 ? notes : undefined;
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
console.warn('Failed to parse liveNotes from frontmatter:', e);
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function parseHighlightsField(raw) {
|
|
309
|
+
if (!raw)
|
|
310
|
+
return undefined;
|
|
311
|
+
try {
|
|
312
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
313
|
+
if (!Array.isArray(parsed))
|
|
314
|
+
return undefined;
|
|
315
|
+
const entries = [];
|
|
316
|
+
for (const item of parsed) {
|
|
317
|
+
if (!item || typeof item !== 'object')
|
|
318
|
+
continue;
|
|
319
|
+
const offsetMs = Number(item.offsetMs);
|
|
320
|
+
if (!Number.isFinite(offsetMs))
|
|
321
|
+
continue;
|
|
322
|
+
const userText = item.userText;
|
|
323
|
+
const subtitle = item.subtitle;
|
|
324
|
+
const bullets = item.bullets;
|
|
325
|
+
entries.push({
|
|
326
|
+
offsetMs: Math.max(0, Math.floor(offsetMs)),
|
|
327
|
+
userText: typeof userText === 'string' ? userText : '',
|
|
328
|
+
subtitle: typeof subtitle === 'string' && subtitle.trim().length > 0 ? subtitle : undefined,
|
|
329
|
+
bullets: Array.isArray(bullets)
|
|
330
|
+
? bullets.map((b) => (typeof b === 'string' ? b : '')).filter((b) => b.length > 0)
|
|
331
|
+
: undefined,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return entries.length > 0 ? entries : undefined;
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
console.warn('Failed to parse highlights from frontmatter:', e);
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
225
341
|
function yamlUnquote(value) {
|
|
226
342
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
227
343
|
return value
|
|
@@ -236,6 +352,9 @@ function yamlUnquote(value) {
|
|
|
236
352
|
function getTranscriptionsDir(dataPath) {
|
|
237
353
|
return path.join(dataPath, 'transcriptions');
|
|
238
354
|
}
|
|
355
|
+
function getResultHighlights(result) {
|
|
356
|
+
return result.highlights && result.highlights.length > 0 ? result.highlights : undefined;
|
|
357
|
+
}
|
|
239
358
|
/**
|
|
240
359
|
* Save transcription as summary.md + transcript.md in a timestamped folder.
|
|
241
360
|
* Returns the created folder path.
|
|
@@ -246,6 +365,7 @@ function saveTranscription(opts) {
|
|
|
246
365
|
const folderPath = path.join(parentDir, folderName);
|
|
247
366
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
248
367
|
const transcribedAt = new Date().toISOString();
|
|
368
|
+
const highlights = getResultHighlights(opts.result);
|
|
249
369
|
// summary.md with frontmatter (stores all raw data for machine reading)
|
|
250
370
|
const frontmatter = buildFrontmatter({
|
|
251
371
|
title: opts.title,
|
|
@@ -259,8 +379,10 @@ function saveTranscription(opts) {
|
|
|
259
379
|
transcribedAt,
|
|
260
380
|
emoji: opts.result.emoji,
|
|
261
381
|
mergedFrom: opts.mergedFrom,
|
|
382
|
+
liveNotes: opts.liveNotes,
|
|
383
|
+
highlights,
|
|
262
384
|
});
|
|
263
|
-
const summaryBody = formatSummary(opts.result, opts.title, opts.mergedFrom);
|
|
385
|
+
const summaryBody = formatSummary(opts.result, opts.title, opts.mergedFrom, opts.liveNotes, highlights);
|
|
264
386
|
fs.writeFileSync(path.join(folderPath, 'summary.md'), `${frontmatter}\n\n${summaryBody}`, 'utf-8');
|
|
265
387
|
// transcript.md
|
|
266
388
|
fs.writeFileSync(path.join(folderPath, 'transcript.md'), formatTranscript(opts.result, opts.title), 'utf-8');
|
|
@@ -355,6 +477,8 @@ async function readTranscription(folderPath) {
|
|
|
355
477
|
console.warn('Failed to parse customFields from frontmatter:', e);
|
|
356
478
|
}
|
|
357
479
|
}
|
|
480
|
+
const liveNotes = parseLiveNotesField(meta.liveNotes);
|
|
481
|
+
const highlights = parseHighlightsField(meta.highlights);
|
|
358
482
|
return {
|
|
359
483
|
title: meta.title || path.basename(folderPath),
|
|
360
484
|
suggestedTitle: meta.suggestedTitle,
|
|
@@ -367,6 +491,8 @@ async function readTranscription(folderPath) {
|
|
|
367
491
|
transcribedAt: meta.transcribedAt,
|
|
368
492
|
emoji: meta.emoji,
|
|
369
493
|
mergedFrom: meta.mergedFrom,
|
|
494
|
+
liveNotes,
|
|
495
|
+
highlights,
|
|
370
496
|
notionPageUrl: meta.notionPageUrl,
|
|
371
497
|
slackSentAt: meta.slackSentAt,
|
|
372
498
|
slackError: meta.slackError,
|
|
@@ -397,6 +523,8 @@ async function updateTranscriptionStatus(folderPath, updates) {
|
|
|
397
523
|
customFields = undefined;
|
|
398
524
|
}
|
|
399
525
|
}
|
|
526
|
+
const liveNotes = parseLiveNotesField(meta.liveNotes);
|
|
527
|
+
const highlights = parseHighlightsField(meta.highlights);
|
|
400
528
|
// Spread first so any unknown frontmatter keys (added by future writers, or
|
|
401
529
|
// by hand-edits) survive the round-trip; named fields override with proper
|
|
402
530
|
// typing and defaults.
|
|
@@ -407,6 +535,8 @@ async function updateTranscriptionStatus(folderPath, updates) {
|
|
|
407
535
|
transcript: meta.transcript || '',
|
|
408
536
|
transcribedAt: meta.transcribedAt || new Date().toISOString(),
|
|
409
537
|
customFields,
|
|
538
|
+
liveNotes,
|
|
539
|
+
highlights,
|
|
410
540
|
};
|
|
411
541
|
applyStatusUpdate(merged, 'notionPageUrl', updates.notionPageUrl);
|
|
412
542
|
applyStatusUpdate(merged, 'slackSentAt', updates.slackSentAt);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "listener-ai",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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": {
|
|
@@ -25,10 +25,11 @@
|
|
|
25
25
|
"build:renderer": "vite build",
|
|
26
26
|
"build:check-renderer": "tsc -p tsconfig.renderer.json",
|
|
27
27
|
"dev:renderer": "vite",
|
|
28
|
-
"lint": "
|
|
29
|
-
"lint:fix": "
|
|
30
|
-
"format": "
|
|
31
|
-
"
|
|
28
|
+
"lint": "oxlint src renderer scripts",
|
|
29
|
+
"lint:fix": "oxlint --fix src renderer scripts",
|
|
30
|
+
"format": "oxfmt 'src/**/*.ts' 'renderer/**/*.ts' 'renderer/**/*.tsx' 'scripts/**/*.js' 'scripts/**/*.ts'",
|
|
31
|
+
"format:check": "oxfmt --check 'src/**/*.ts' 'renderer/**/*.ts' 'renderer/**/*.tsx' 'scripts/**/*.js' 'scripts/**/*.ts'",
|
|
32
|
+
"test": "tsc && node --test \"dist/**/*.test.js\"",
|
|
32
33
|
"dist": "pnpm run build && electron-builder",
|
|
33
34
|
"dist:mac": "pnpm run build && electron-builder --mac",
|
|
34
35
|
"dist:mac-x64": "pnpm run build && electron-builder --mac --x64",
|
|
@@ -53,14 +54,15 @@
|
|
|
53
54
|
},
|
|
54
55
|
"homepage": "https://github.com/asleep-ai/listener-ai#readme",
|
|
55
56
|
"devDependencies": {
|
|
56
|
-
"@biomejs/biome": "^1.9.4",
|
|
57
57
|
"@electron/notarize": "^3.0.1",
|
|
58
58
|
"@types/fs-extra": "^11.0.4",
|
|
59
59
|
"@types/node": "^24.0.12",
|
|
60
60
|
"dotenv": "^17.2.0",
|
|
61
61
|
"electron": "^39.8.5",
|
|
62
|
-
"vite": "^
|
|
62
|
+
"vite": "^6.4.2",
|
|
63
63
|
"electron-builder": "^26.8.1",
|
|
64
|
+
"oxfmt": "^0.48.0",
|
|
65
|
+
"oxlint": "^1.63.0",
|
|
64
66
|
"semver": "^7.7.2",
|
|
65
67
|
"typescript": "^5.8.3"
|
|
66
68
|
},
|