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.
@@ -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.5.0",
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": "biome check .",
29
- "lint:fix": "biome check --write .",
30
- "format": "biome format --write .",
31
- "test": "tsc && node --test dist/meetingDetectorService.test.js dist/searchService.test.js dist/agentService.test.js dist/simpleAudioRecorder.test.js dist/outputService.test.js dist/slackService.test.js dist/services/audioConcatService.test.js dist/cli.test.js",
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": "^5.4.10",
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
  },