create-pmos 0.177.0 → 0.179.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-sessions-panel-test.js +12 -3
- package/workspace/guide/console-calendar.md +17 -2
- package/workspace/guide/console3-sessions.md +2 -0
- package/workspace/lib/notes-series.js +357 -0
- package/workspace/lib/notes.js +53 -4
- package/workspace/lib/routes-notes.js +27 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-notes.js +299 -34
- package/workspace/public/pm/c3-session-term.js +15 -1
- package/workspace/public/pm/c3-shell.js +3 -2
- package/workspace/public/pm/console3.css +13 -0
- package/workspace/run-tests.js +25 -1
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/c3-notes-series.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.179.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -30,6 +30,12 @@ const finish = async (code) => {
|
|
|
30
30
|
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
31
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
32
|
async function until(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(150); } return null; }
|
|
33
|
+
/* the Sessions panel: the Sessions button opens a running last session's terminal, whose "All sessions" opens the panel */
|
|
34
|
+
async function openPanel(page) {
|
|
35
|
+
await page.click('[data-c3pg="agent-button"]');
|
|
36
|
+
const got = await page.waitForSelector('[data-c3t="host"], [data-c3-all-sessions]', { timeout: 15000 }).catch(() => null);
|
|
37
|
+
if (got && await page.$('[data-c3-all-sessions]')) await page.click('[data-c3-all-sessions]');
|
|
38
|
+
}
|
|
33
39
|
async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
|
|
34
40
|
const get = (p) => fetch(BASE + p).then((r) => r.json());
|
|
35
41
|
// the agent CLI's home for this run: the conversation test writes a session record and a transcript here, as claude would
|
|
@@ -175,8 +181,11 @@ function startShell(name) {
|
|
|
175
181
|
await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
|
|
176
182
|
|
|
177
183
|
/* ── D: End asks first, then ends ── */
|
|
184
|
+
// Sessions opens the terminal of the session opened last while it runs (PM, 2026-10-13); its "All sessions" opens the panel
|
|
178
185
|
await page.click('[data-c3pg="agent-button"]');
|
|
179
|
-
ok('D0 the
|
|
186
|
+
ok('D0 Sessions opens the terminal of the session opened last', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
|
|
187
|
+
await page.click('[data-c3-all-sessions]');
|
|
188
|
+
ok('D0a All sessions in the terminal sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session-term]')));
|
|
180
189
|
ok('D0b it opens on the session opened last: its row is open and marked', await waitFor(page, (i) => { const li = document.querySelector(`[data-c3t="mine"] [data-run="${i}"]`); const end = document.querySelector(`[data-end-session="${i}"]`); return !!li && li.hasAttribute('data-last-open') && !!end && end.offsetParent !== null; }, id)
|
|
181
190
|
&& await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
|
|
182
191
|
if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
|
|
@@ -232,7 +241,7 @@ function startShell(name) {
|
|
|
232
241
|
|
|
233
242
|
/* ── F: New session starts on console3 and opens its terminal here (c3-new-session; it went to the classic app before) ── */
|
|
234
243
|
const before = new Set(((await get('/api/sessions')).sessions || []).map((s) => s.id));
|
|
235
|
-
if (!(await page.isVisible('[data-c3t="new"]').catch(() => false))) await page
|
|
244
|
+
if (!(await page.isVisible('[data-c3t="new"]').catch(() => false))) await openPanel(page);
|
|
236
245
|
ok('F1 Started by me has a New button', await waitFor(page, () => !!document.querySelector('[data-c3t="new"]')));
|
|
237
246
|
await page.click('[data-c3t="new"]');
|
|
238
247
|
ok('F2 New opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
|
|
@@ -261,7 +270,7 @@ function startShell(name) {
|
|
|
261
270
|
|
|
262
271
|
/* ── P: a past session can be removed, also one the server knows only from its record on disk ── */
|
|
263
272
|
if (await page.isVisible('[data-shell="sheet"] [aria-label="Close"]').catch(() => false)) await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
264
|
-
if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await page
|
|
273
|
+
if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await openPanel(page);
|
|
265
274
|
const pastSel = '[data-c3t="past"] [data-run="sess-9901"]';
|
|
266
275
|
ok('P1 the ended session is listed in Past', await waitFor(page, (s) => !!document.querySelector(s), pastSel));
|
|
267
276
|
if (!(await page.isVisible('[data-end-session="sess-9901"]').catch(() => false))) await page.click(pastSel + ' .nb');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: the Calendar tab of the console page"
|
|
4
4
|
description: "The Calendar tab of the console page (/): Calendar and Notes, the five zooms, the highlights, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-13
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: the Calendar tab of the console page
|
|
@@ -13,7 +13,22 @@ The Calendar tab of the console page (`/`) shows time. It shows what happens and
|
|
|
13
13
|
|
|
14
14
|
The switch at the start of the bar has two parts. **Calendar** shows the grid. **Notes** shows your notes.
|
|
15
15
|
|
|
16
|
-
In **Notes**, the list is at the left, under the heading **Notes**. The notes are in groups by what they are about: **Portfolio** first, then one group for each board, in the order of the portfolio. **Not linked** is the last group. A note linked to an initiative from an earlier version, with no board, is in **Initiatives**. In each group, the newest meeting is first. A note with no meeting is placed by when its recording started, or else by when it was made. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes
|
|
16
|
+
In **Notes**, the list is at the left, under the heading **Notes**. The notes are in groups by what they are about: **Portfolio** first, then one group for each board, in the order of the portfolio. **Not linked** is the last group. A note linked to an initiative from an earlier version, with no board, is in **Initiatives**. In each group, the newest meeting is first. A note with no meeting is placed by when its recording started, or else by when it was made. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes that have every word. The search reads the title, the board, the initiative, the summary, the decisions, the next steps and what was said. A private note is found by its title only. A row shows when the note was made and its length. In a board group, a row shows its initiative, or **board** when the note is about the whole board. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
|
|
17
|
+
|
|
18
|
+
Notes of one regular meeting are a **series**: the same calendar series, or the same title. A series is one row. The row shows how many meetings it has, and how many next steps are still open. Press the arrow on the row to show its last three meetings. Press the row to open the series at the right. Notes older than 30 days fold into **Older than 30 days** at the end of each group. Press it to show them. A recorder left on after the meeting shows its length up to the last line heard, not up to when it stopped.
|
|
19
|
+
|
|
20
|
+
The series page shows what carries from one meeting to the next:
|
|
21
|
+
|
|
22
|
+
- **Next one** shows the next meeting of the series and what to bring up: the next steps still open after several meetings, the questions asked in several meetings, and your own lines. Type in **Add something to bring up** and press `Enter` to add a line. Press the cross to remove it.
|
|
23
|
+
- **Still open** shows each next step not yet done, its owner, the number of meetings it was raised in, and when it was last heard. Press **Done** when it is done. It stops carrying over. **Open again** puts it back.
|
|
24
|
+
- **Decided so far** shows each decision of the series, newest first, with why.
|
|
25
|
+
- **Keeps coming up** shows the topics heard in several meetings.
|
|
26
|
+
- **Still unanswered** shows the open questions. Press **Answered** when a question has its answer.
|
|
27
|
+
- **Every meeting** shows each note of the series with its gist. Press one to open it.
|
|
28
|
+
|
|
29
|
+
A private note adds its title and date to the series, and nothing else.
|
|
30
|
+
|
|
31
|
+
A note in a series shows the series and its place, for example `IDPF - DSU · 21 of 21`. Press it to open the series. The arrows open the meeting before and the meeting after. **After** shows **Since the last one** first: each next step of the meeting before, as **Done**, **Raised again** or **Not mentioned**, then the new next steps. Press **Not part of this series** to take a note out of its series. Press **Add to a series** to put it in one.
|
|
17
32
|
|
|
18
33
|
Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
|
|
19
34
|
|
|
@@ -9,6 +9,8 @@ timestamp: 2026-10-13
|
|
|
9
9
|
|
|
10
10
|
Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
|
|
11
11
|
|
|
12
|
+
When the session that you opened last is still running, the **Sessions** button opens its terminal directly. To see all your sessions, press **All sessions** at the top of the terminal sheet. If that session has ended, the button opens the panel.
|
|
13
|
+
|
|
12
14
|
The panel opens on the session that you opened last. Its row is open, so you can read the conversation and send a message without searching for it. A session counts as opened when you open its row or its terminal.
|
|
13
15
|
|
|
14
16
|
## The dock
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-series.js — the notes of one regular meeting, kept together (initiative c3-notes-series, the mockup the PM approved on
|
|
3
|
+
* 2026-10-13: https://claude.ai/artifact/Q4SSHcj6QHhpQAiVAx52x6).
|
|
4
|
+
*
|
|
5
|
+
* assign(rows) the list's rows get their series: a title key joins the calendar series with the same title
|
|
6
|
+
* thread(dir, key, { now, events }) → { ok, series } the series page: next one, still open, decided, keeps coming up, unanswered, every meeting
|
|
7
|
+
* placeOf(dir, id, { now }) → { key, title, pos, count, prev, next, since, carried_in, carried } | null a note in its series
|
|
8
|
+
* search(dir, q) → { ok, ids } the notes whose title, notes or transcript hold every word of q
|
|
9
|
+
* markDone(dir, { key, text, kind, done }) · bring(dir, { key, text }) · unbring(dir, { key, id }) the PM's marks, per series
|
|
10
|
+
*
|
|
11
|
+
* A SERIES is the notes with one key (lib/notes.js seriesOf): the calendar series ('s:<id>'), else the normalized title
|
|
12
|
+
* ('t:<title>'). A note with a title key joins the calendar series whose notes have the same normalized title, so a note taken
|
|
13
|
+
* without the calendar link still lands in its meeting. A key with fewer than two notes is not a series.
|
|
14
|
+
*
|
|
15
|
+
* SAME ITEM. Two actions (or questions) are the same when their words match after lower-casing and dropping punctuation. No model
|
|
16
|
+
* is called and nothing is sent outward. An action is open until the PM marks it done; a question until the PM marks it answered.
|
|
17
|
+
* Those marks, and the PM's lines to bring up, live in <dir>/notes-series.json, keyed by series.
|
|
18
|
+
*
|
|
19
|
+
* A PRIVATE note adds its date and title to a series, never its content (as the calendar's meeting card does). */
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const notes = require('./notes.js');
|
|
24
|
+
const calMem = require('./calendar-memory.js');
|
|
25
|
+
|
|
26
|
+
const OPEN_WINDOW = 6; // actions and questions come from the last six meetings
|
|
27
|
+
const TOPIC_WINDOW = 10; // topics count over the last ten meetings
|
|
28
|
+
const TOPIC_MIN = 3; // a topic comes up in three or more of them
|
|
29
|
+
const CLOSED_DAYS = 14; // closed actions shown under Still open
|
|
30
|
+
const CARRY_WARN = 3; // an action carried in this many meetings is brought up
|
|
31
|
+
const MAX_BRING = 20;
|
|
32
|
+
const DAY = 86400000;
|
|
33
|
+
|
|
34
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
35
|
+
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
36
|
+
const err = (status, error) => ({ ok: false, status, error });
|
|
37
|
+
const startOf = (n) => (n && n.meeting && n.meeting.start) || (n && n.recorded_from) || (n && n.created_at) || null;
|
|
38
|
+
const timeOf = (n) => Date.parse(startOf(n)) || 0;
|
|
39
|
+
|
|
40
|
+
/* ── the list ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
/** Give each row its series key (rows carry `series` from notes.js seriesOf). A title key moves to the calendar series whose
|
|
43
|
+
* notes share that title. Sets row.series = { key, by } or null; by is 'calendar', 'title' or 'pm'. Changes the rows. */
|
|
44
|
+
function assign(rows) {
|
|
45
|
+
const byTitle = new Map();
|
|
46
|
+
for (const r of rows) {
|
|
47
|
+
if (!r.series || r.series.by !== 'calendar') continue;
|
|
48
|
+
const t = calMem.normTitle((r.meeting && r.meeting.title) || r.title);
|
|
49
|
+
if (t && !byTitle.has(t)) byTitle.set(t, r.series.key);
|
|
50
|
+
}
|
|
51
|
+
for (const r of rows) {
|
|
52
|
+
if (!r.series || r.series.by !== 'title') continue;
|
|
53
|
+
const k = byTitle.get(r.series.key.slice(2));
|
|
54
|
+
if (k) r.series = { key: k, by: 'title' };
|
|
55
|
+
}
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every note row with its series, the same as GET /api/notes. */
|
|
60
|
+
function rowsOf(dir, now) { return (notes.list(dir, { now }).notes || []); }
|
|
61
|
+
|
|
62
|
+
/* ── a note's parts ───────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
/** What a note holds: its final notes after Stop, else its saved local summary, else a local summary made now. */
|
|
65
|
+
const partsCache = new WeakMap(); // a cached note (notes.js loadCached) → its parts, made again only when the file changes
|
|
66
|
+
function partsOf(n) {
|
|
67
|
+
if (n && typeof n === 'object' && partsCache.has(n)) return partsCache.get(n);
|
|
68
|
+
const p = partsNow(n);
|
|
69
|
+
if (n && typeof n === 'object') partsCache.set(n, p);
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
function partsNow(n) {
|
|
73
|
+
const empty = { overview: '', decisions: [], actions: [], questions: [] };
|
|
74
|
+
if (!n || n.private) return empty;
|
|
75
|
+
const f = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
|
|
76
|
+
let sec = n.summary && n.summary.sections ? n.summary.sections : null;
|
|
77
|
+
if (!f && !sec && ((n.transcript || []).length || (n.bullets || []).length)) { try { sec = notes.summarize(n).sections; } catch { sec = null; } }
|
|
78
|
+
const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
|
|
79
|
+
const ov = n.overview_pm || (f && (f.overview || f.purpose))
|
|
80
|
+
|| (sec && Array.isArray(sec.overview) ? sec.overview.slice(0, 3).map((x) => String((x && x.text) || '')).filter(Boolean).join(' ') : '');
|
|
81
|
+
const items = (xs, extra) => xs.filter((x) => x && clip(x.text, 400)).map((x) => ({ text: clip(x.text, 400), ...extra(x) }));
|
|
82
|
+
return {
|
|
83
|
+
overview: clip(ov, 600),
|
|
84
|
+
decisions: items(pick(f && f.decisions, sec && sec.decisions), (x) => ({ why: clip(x.why, 300) || null })),
|
|
85
|
+
actions: items(pick(f && f.actions, sec && sec.actions), (x) => ({ owner: clip(x.owner, 80) || null, due: clip(x.due, 60) || null, quote: clip(x.quote, 300) || null })),
|
|
86
|
+
questions: items(pick(f && f.questions, sec && sec.questions), (x) => ({ quote: clip(x.quote, 300) || null })),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The notes of one series, oldest first, each read in full with its parts. */
|
|
91
|
+
function membersOf(dir, key, rows) {
|
|
92
|
+
return rows.filter((r) => r.series && r.series.key === key)
|
|
93
|
+
.map((r) => ({ row: r, note: notes.loadCached(dir, r.id) }))
|
|
94
|
+
.filter((x) => x.note)
|
|
95
|
+
.sort((a, b) => timeOf(a.note) - timeOf(b.note))
|
|
96
|
+
.map((x) => ({ ...x, at: startOf(x.note), parts: partsOf(x.note) }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── the PM's marks ───────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
const storeFile = (dir) => path.join(dir, 'notes-series.json');
|
|
102
|
+
function readStore(dir) {
|
|
103
|
+
try { const j = JSON.parse(fs.readFileSync(storeFile(dir), 'utf8')); return j && typeof j === 'object' && !Array.isArray(j) ? j : {}; } catch { return {}; }
|
|
104
|
+
}
|
|
105
|
+
function writeStore(dir, s) {
|
|
106
|
+
const f = storeFile(dir);
|
|
107
|
+
const tmp = f + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
fs.writeFileSync(tmp, JSON.stringify(s, null, 1));
|
|
110
|
+
fs.renameSync(tmp, f);
|
|
111
|
+
}
|
|
112
|
+
const entryOf = (s, key) => { const e = s[key] && typeof s[key] === 'object' ? s[key] : {}; return { done: e.done && typeof e.done === 'object' ? e.done : {}, bring: Array.isArray(e.bring) ? e.bring : [] }; };
|
|
113
|
+
const validKey = (key) => typeof key === 'string' && notes.SERIES_KEY_RE.test(key);
|
|
114
|
+
|
|
115
|
+
/** Mark an action done (kind 'action') or a question answered (kind 'question'), or take the mark back (done: false). */
|
|
116
|
+
function markDone(dir, { key, text, kind = 'action', done = true, now = Date.now() } = {}) {
|
|
117
|
+
if (!validKey(key)) return err(400, 'that is not a series');
|
|
118
|
+
const k = norm(text);
|
|
119
|
+
if (!k) return err(400, 'say which item');
|
|
120
|
+
if (!['action', 'question'].includes(kind)) return err(400, 'the kind must be action or question');
|
|
121
|
+
const s = readStore(dir); const e = entryOf(s, key);
|
|
122
|
+
if (done) e.done[k] = { text: clip(text, 400), kind, at: new Date(now).toISOString() }; else delete e.done[k];
|
|
123
|
+
s[key] = e; writeStore(dir, s);
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
/** Add a line to bring up at the next meeting of the series. */
|
|
127
|
+
function bring(dir, { key, text, now = Date.now() } = {}) {
|
|
128
|
+
if (!validKey(key)) return err(400, 'that is not a series');
|
|
129
|
+
const t = clip(text, 300);
|
|
130
|
+
if (!t) return err(400, 'write what to bring up');
|
|
131
|
+
const s = readStore(dir); const e = entryOf(s, key);
|
|
132
|
+
if (e.bring.length >= MAX_BRING) return err(400, 'this series already has ' + MAX_BRING + ' lines to bring up');
|
|
133
|
+
const line = { id: 'b-' + Math.floor(now).toString(36) + crypto.randomBytes(2).toString('hex'), text: t, at: new Date(now).toISOString() };
|
|
134
|
+
e.bring.push(line); s[key] = e; writeStore(dir, s);
|
|
135
|
+
return { ok: true, line };
|
|
136
|
+
}
|
|
137
|
+
function unbring(dir, { key, id } = {}) {
|
|
138
|
+
if (!validKey(key)) return err(400, 'that is not a series');
|
|
139
|
+
const s = readStore(dir); const e = entryOf(s, key);
|
|
140
|
+
const before = e.bring.length;
|
|
141
|
+
e.bring = e.bring.filter((b) => b.id !== id);
|
|
142
|
+
if (e.bring.length === before) return err(404, 'no such line');
|
|
143
|
+
s[key] = e; writeStore(dir, s);
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── the thread ───────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
const STOP = new Set(('the a an and or of to in on for with is are was be it this that we you i at by from as but not if so do does did will can '
|
|
150
|
+
+ 'should would could has have had they he she them their our us all any some more most just also into about over than then when what who which '
|
|
151
|
+
+ 'how why there here yes no ok okay get got go going need needs make made let lets one two next last week today tomorrow monday tuesday wednesday '
|
|
152
|
+
+ 'thursday friday team meeting call update updates').split(' '));
|
|
153
|
+
const wordsIn = (s) => norm(s).split(' ').filter((w) => w.length > 2 && !STOP.has(w) && !/^\d+$/.test(w));
|
|
154
|
+
|
|
155
|
+
/** For each agenda item, whether a transcript line shares two of its words (notes-context.js agendaHeard, on the server). */
|
|
156
|
+
function agendaHeard(agenda, lines) {
|
|
157
|
+
const ls = lines.map((l) => new Set(wordsIn(l.text)));
|
|
158
|
+
return agenda.map((a) => {
|
|
159
|
+
const aw = [...new Set(wordsIn(a))]; const need = Math.min(2, aw.length);
|
|
160
|
+
return need > 0 && ls.some((w) => aw.filter((x) => w.has(x)).length >= need);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The topics that keep coming up over the last TOPIC_WINDOW meetings: agenda items heard, then two-word phrases from the
|
|
165
|
+
* notes, each in TOPIC_MIN or more meetings. → [{ name, hits: [bool, newest first], count, kind: 'agenda'|'phrase' }] */
|
|
166
|
+
function topicsOf(ms, seriesTitle) {
|
|
167
|
+
const recent = ms.filter((m) => !m.note.private).slice(-TOPIC_WINDOW).reverse();
|
|
168
|
+
if (recent.length < TOPIC_MIN) return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
const agenda = new Map(); // norm → { name, hits }
|
|
171
|
+
recent.forEach((m, i) => {
|
|
172
|
+
const c = m.note.meeting && m.note.meeting.context;
|
|
173
|
+
const items = c && Array.isArray(c.agenda) ? c.agenda.map((a) => clip(a, 120)).filter(Boolean) : [];
|
|
174
|
+
if (!items.length) return;
|
|
175
|
+
const heard = agendaHeard(items, notes.transcriptLines(m.note) || []);
|
|
176
|
+
items.forEach((a, j) => {
|
|
177
|
+
if (!heard[j]) return;
|
|
178
|
+
const k = norm(a); if (!agenda.has(k)) agenda.set(k, { name: a, hits: recent.map(() => false) });
|
|
179
|
+
agenda.get(k).hits[i] = true;
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
for (const t of agenda.values()) { const count = t.hits.filter(Boolean).length; if (count >= TOPIC_MIN) out.push({ name: t.name, hits: t.hits, count, kind: 'agenda' }); }
|
|
183
|
+
const skip = new Set(wordsIn(seriesTitle));
|
|
184
|
+
const phrases = new Map(); // "w1 w2" → { name, hits }
|
|
185
|
+
recent.forEach((m, i) => {
|
|
186
|
+
const p = m.parts;
|
|
187
|
+
const texts = [p.overview, ...p.decisions.map((x) => x.text), ...p.actions.map((x) => x.text), ...p.questions.map((x) => x.text)];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const t of texts) {
|
|
190
|
+
const raw = String(t || '').split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
191
|
+
const ws = raw.map((w) => w.toLowerCase());
|
|
192
|
+
for (let j = 0; j + 1 < ws.length; j++) {
|
|
193
|
+
const a = ws[j]; const b = ws[j + 1];
|
|
194
|
+
// a short word counts only as an acronym ("QA", "UX")
|
|
195
|
+
const short = (w, r) => w.length < 3 && !/^[A-Z]{2}$/.test(r);
|
|
196
|
+
if (short(a, raw[j]) || short(b, raw[j + 1]) || STOP.has(a) || STOP.has(b) || /^\d+$/.test(a) || /^\d+$/.test(b) || (skip.has(a) && skip.has(b))) continue;
|
|
197
|
+
const k = a + ' ' + b;
|
|
198
|
+
if (seen.has(k)) continue;
|
|
199
|
+
seen.add(k);
|
|
200
|
+
if (!phrases.has(k)) phrases.set(k, { name: raw[j] + ' ' + raw[j + 1], hits: recent.map(() => false) });
|
|
201
|
+
phrases.get(k).hits[i] = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const inAgenda = (k) => out.some((t) => k.split(' ').every((w) => norm(t.name).split(' ').includes(w)));
|
|
206
|
+
const ph = [...phrases.entries()].map(([k, t]) => ({ k, ...t, count: t.hits.filter(Boolean).length }))
|
|
207
|
+
.filter((t) => t.count >= TOPIC_MIN && !inAgenda(t.k)).sort((a, b) => b.count - a.count || a.k.localeCompare(b.k));
|
|
208
|
+
for (const t of ph) {
|
|
209
|
+
if (out.length >= 6) break;
|
|
210
|
+
// a phrase that shares a word with a stronger one already kept is the same topic
|
|
211
|
+
if (out.some((o) => o.kind === 'phrase' && o.k.split(' ').some((w) => t.k.split(' ').includes(w)))) continue;
|
|
212
|
+
out.push({ name: t.name.charAt(0).toUpperCase() + t.name.slice(1), hits: t.hits, count: t.count, kind: 'phrase', k: t.k });
|
|
213
|
+
}
|
|
214
|
+
return out.map(({ k, ...t }) => t);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The items of one kind across the meetings: each distinct item with when it was first raised, in how many meetings, and the
|
|
218
|
+
* last time it was raised. `window` limits which meetings an item must appear in to be listed. */
|
|
219
|
+
function carriedOf(ms, kind, window) {
|
|
220
|
+
const all = new Map(); // norm → { text, owner, due, first, seen, last }
|
|
221
|
+
for (const m of ms) {
|
|
222
|
+
if (m.note.private) continue;
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
for (const x of m.parts[kind]) {
|
|
225
|
+
const k = norm(x.text);
|
|
226
|
+
if (!k || seen.has(k)) continue;
|
|
227
|
+
seen.add(k);
|
|
228
|
+
const e = all.get(k) || { key: k, first: m.at, seen: 0 };
|
|
229
|
+
e.seen++; e.text = x.text; e.owner = x.owner || e.owner || null; e.due = x.due || e.due || null;
|
|
230
|
+
e.last = { at: m.at, note: m.note.id, quote: x.quote || null };
|
|
231
|
+
all.set(k, e);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const recent = new Set(ms.filter((m) => !m.note.private).slice(-window).map((m) => m.note.id));
|
|
235
|
+
return [...all.values()].filter((e) => recent.has(e.last.note));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The next meeting of the series on the calendar, after `now`. */
|
|
239
|
+
function nextOf(key, events, now) {
|
|
240
|
+
const t = key.slice(2);
|
|
241
|
+
return (events || []).filter((e) => e && e.title && Date.parse(e.at) > now)
|
|
242
|
+
.filter((e) => (key.startsWith('s:') ? (e.series && 's:' + e.series === key) : false) || calMem.normTitle(e.title) === (key.startsWith('t:') ? t : null))
|
|
243
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
|
244
|
+
.map((e) => ({ id: e.id, at: e.at, end: e.end || null, title: e.title }))[0] || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function thread(dir, key, { now = Date.now(), events = null, rows = null } = {}) {
|
|
248
|
+
if (!validKey(key)) return err(400, 'that is not a series');
|
|
249
|
+
const all = rows || rowsOf(dir, now);
|
|
250
|
+
const ms = membersOf(dir, key, all);
|
|
251
|
+
if (!ms.length) return err(404, 'no notes in this series');
|
|
252
|
+
if (events === null) { try { events = require('./calendar-events.js').readAll(dir, { now }); } catch { events = []; } }
|
|
253
|
+
// a series with a title key whose notes came from the calendar still finds its meetings by the calendar series
|
|
254
|
+
const seriesIds = [...new Set(ms.map((m) => m.note.meeting && m.note.meeting.context && m.note.meeting.context.series).filter(Boolean))];
|
|
255
|
+
const st = entryOf(readStore(dir), key);
|
|
256
|
+
const newest = ms[ms.length - 1];
|
|
257
|
+
const title = clip((newest.note.meeting && newest.note.meeting.title) || newest.note.title, 160) || 'Untitled meeting';
|
|
258
|
+
const hold = (e) => !st.done[e.key];
|
|
259
|
+
const actions = carriedOf(ms, 'actions', OPEN_WINDOW);
|
|
260
|
+
const open = actions.filter(hold).sort((a, b) => b.seen - a.seen || String(b.last.at).localeCompare(String(a.last.at)));
|
|
261
|
+
const closed = Object.entries(st.done).filter(([, v]) => v && v.kind === 'action' && now - Date.parse(v.at) <= CLOSED_DAYS * DAY)
|
|
262
|
+
.map(([k, v]) => ({ key: k, text: v.text, at: v.at })).sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
263
|
+
const questions = carriedOf(ms, 'questions', OPEN_WINDOW).filter(hold).sort((a, b) => String(b.last.at).localeCompare(String(a.last.at)));
|
|
264
|
+
const decisions = [];
|
|
265
|
+
for (const m of ms.slice().reverse()) for (const d of m.parts.decisions) decisions.push({ text: d.text, why: d.why, at: m.at, note: m.note.id });
|
|
266
|
+
const meetings = ms.map((m, i) => {
|
|
267
|
+
const prev = i > 0 ? new Set(ms[i - 1].parts.actions.map((x) => norm(x.text))) : new Set();
|
|
268
|
+
return { id: m.note.id, title: clip(m.note.title, 160), at: m.at, ms: m.row.meeting_ms || 0, private: !!m.note.private, by: m.row.series.by,
|
|
269
|
+
overview: m.parts.overview || (m.row.summary_lines || []).join(' ').slice(0, 300), decisions: m.parts.decisions.length,
|
|
270
|
+
new_actions: m.parts.actions.filter((x) => !prev.has(norm(x.text))).length };
|
|
271
|
+
}).reverse();
|
|
272
|
+
let next = nextOf(key, events, now);
|
|
273
|
+
if (!next) for (const s of seriesIds) { next = nextOf('s:' + s, events, now); if (next) break; }
|
|
274
|
+
const derived = [
|
|
275
|
+
...open.filter((e) => e.seen >= CARRY_WARN).slice(0, 3).map((e) => ({ kind: 'carried', text: e.text, owner: e.owner, seen: e.seen })),
|
|
276
|
+
...questions.filter((e) => e.seen >= 2).slice(0, 2).map((e) => ({ kind: 'question', text: e.text, seen: e.seen })),
|
|
277
|
+
];
|
|
278
|
+
const by = new Set(ms.map((m) => m.row.series.by));
|
|
279
|
+
return { ok: true, series: {
|
|
280
|
+
key, title, count: ms.length, since: ms[0].at, joined: { calendar: by.has('calendar'), title: ms.filter((m) => m.row.series.by === 'title').length, pm: ms.filter((m) => m.row.series.by === 'pm').length },
|
|
281
|
+
about: { portfolio: !!newest.note.portfolio, board: newest.note.board || null, initiative: newest.note.initiative || null },
|
|
282
|
+
next: next ? { ...next, bring: st.bring, derived } : { bring: st.bring, derived },
|
|
283
|
+
open, closed, decisions: decisions.slice(0, 60), questions, topics: topicsOf(ms, title), meetings } };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A note in its series: where it sits, the meeting before and after, and what changed since the meeting before. */
|
|
287
|
+
function placeOf(dir, id, { now = Date.now(), rows = null } = {}) {
|
|
288
|
+
const all = rows || rowsOf(dir, now);
|
|
289
|
+
const r = all.find((x) => x.id === id);
|
|
290
|
+
if (!r || !r.series) return null;
|
|
291
|
+
const ms = membersOf(dir, r.series.key, all);
|
|
292
|
+
if (ms.length < 2) return null;
|
|
293
|
+
const i = ms.findIndex((m) => m.note.id === id);
|
|
294
|
+
if (i < 0) return null;
|
|
295
|
+
const st = entryOf(readStore(dir), r.series.key);
|
|
296
|
+
const me = ms[i]; const prev = i > 0 ? ms[i - 1] : null; const next = i + 1 < ms.length ? ms[i + 1] : null;
|
|
297
|
+
const mine = new Set(me.parts.actions.map((x) => norm(x.text)));
|
|
298
|
+
const before = prev && !prev.note.private ? prev.parts.actions : [];
|
|
299
|
+
const prevKeys = new Set(before.map((x) => norm(x.text)));
|
|
300
|
+
const items = before.map((x) => ({ text: x.text, owner: x.owner, state: st.done[norm(x.text)] ? 'done' : mine.has(norm(x.text)) ? 'again' : 'quiet' }));
|
|
301
|
+
const fresh = me.parts.actions.filter((x) => !prevKeys.has(norm(x.text))).map((x) => ({ text: x.text, owner: x.owner }));
|
|
302
|
+
// how many meetings up to this one raised each of its actions ("carried · meeting N")
|
|
303
|
+
const carried = {};
|
|
304
|
+
for (const x of me.parts.actions) { const k = norm(x.text); const n = ms.slice(0, i + 1).filter((m) => !m.note.private && m.parts.actions.some((y) => norm(y.text) === k)).length; if (n >= 2) carried[k] = n; }
|
|
305
|
+
const carriedIn = items.filter((x) => x.state !== 'done');
|
|
306
|
+
const title = clip((ms[ms.length - 1].note.meeting && ms[ms.length - 1].note.meeting.title) || ms[ms.length - 1].note.title, 160);
|
|
307
|
+
return { key: r.series.key, title, pos: i + 1, count: ms.length, by: r.series.by,
|
|
308
|
+
prev: prev ? { id: prev.note.id, at: prev.at, overview: prev.note.private ? '' : prev.parts.overview } : null,
|
|
309
|
+
next: next ? { id: next.note.id, at: next.at } : null,
|
|
310
|
+
since: prev ? { at: prev.at, items, fresh } : null, carried_in: carriedIn, carried };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How many actions each series carries open, for the list's "N open": { key: count }, only for keys with two or more notes. */
|
|
314
|
+
function openCounts(dir, rows) {
|
|
315
|
+
const by = new Map();
|
|
316
|
+
for (const r of rows) if (r.series) by.set(r.series.key, (by.get(r.series.key) || 0) + 1);
|
|
317
|
+
const store = readStore(dir);
|
|
318
|
+
const out = {};
|
|
319
|
+
for (const [key, n] of by) {
|
|
320
|
+
if (n < 2) continue;
|
|
321
|
+
const st = entryOf(store, key);
|
|
322
|
+
const open = carriedOf(membersOf(dir, key, rows), 'actions', OPEN_WINDOW).filter((e) => !st.done[e.key]);
|
|
323
|
+
if (open.length) out[key] = { open: open.length, carried: open.filter((e) => e.seen >= CARRY_WARN).length };
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ── search ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
329
|
+
|
|
330
|
+
/** The notes whose text holds every word of `q`: the title always; for a note that is not private, also its overview, final
|
|
331
|
+
* notes, typed notes, summary and transcript. */
|
|
332
|
+
function search(dir, q) {
|
|
333
|
+
const ws = norm(q).split(' ').filter(Boolean);
|
|
334
|
+
if (!ws.length || norm(q).length < 2) return { ok: true, ids: [] };
|
|
335
|
+
let names = [];
|
|
336
|
+
try { names = fs.readdirSync(notes.notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, ids: [] }; }
|
|
337
|
+
const ids = [];
|
|
338
|
+
for (const f of names) {
|
|
339
|
+
const n = notes.loadCached(dir, f.slice(0, -5));
|
|
340
|
+
if (!n || !n.id) continue;
|
|
341
|
+
let hay = n.title || '';
|
|
342
|
+
if (!n.private) {
|
|
343
|
+
const fin = n.final_notes || {};
|
|
344
|
+
const items = (xs) => (Array.isArray(xs) ? xs.map((x) => (x && x.text) || '').join(' ') : '');
|
|
345
|
+
const sec = (n.summary && n.summary.sections) || {};
|
|
346
|
+
hay += ' ' + [n.overview_pm, fin.overview, fin.purpose, fin.discussion, items(fin.decisions), items(fin.actions), items(fin.questions), n.body,
|
|
347
|
+
items(sec.overview), items(sec.decisions), items(sec.actions), items(sec.questions), items(n.bullets), items(n.transcript),
|
|
348
|
+
n.meeting && n.meeting.title].filter(Boolean).join(' ');
|
|
349
|
+
}
|
|
350
|
+
const h = ' ' + norm(hay) + ' ';
|
|
351
|
+
if (ws.every((w) => h.includes(w))) ids.push(n.id);
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, ids };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = { assign, openCounts, thread, placeOf, search, markDone, bring, unbring, partsOf, topicsOf, carriedOf, nextOf, norm, storeFile,
|
|
357
|
+
OPEN_WINDOW, TOPIC_WINDOW, TOPIC_MIN, CARRY_WARN };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -111,6 +111,19 @@ function load(dir, id) {
|
|
|
111
111
|
if (!ID_RE.test(String(id || ''))) return null;
|
|
112
112
|
try { return JSON.parse(fs.readFileSync(fileOf(dir, id), 'utf8')); } catch { return null; }
|
|
113
113
|
}
|
|
114
|
+
/* The list's reads (c3-notes-series): a note file is parsed again only when its modified time or size changed, so a list call
|
|
115
|
+
* with many notes reads only the notes that changed. The cached note is shared: a reader must not change it. */
|
|
116
|
+
const cached = new Map(); // file → { mtimeMs, size, note }
|
|
117
|
+
function loadCached(dir, id) {
|
|
118
|
+
if (!ID_RE.test(String(id || ''))) return null;
|
|
119
|
+
const f = fileOf(dir, id);
|
|
120
|
+
let st; try { st = fs.statSync(f); } catch { cached.delete(f); return null; }
|
|
121
|
+
const hit = cached.get(f);
|
|
122
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.note;
|
|
123
|
+
const note = load(dir, id);
|
|
124
|
+
if (note) cached.set(f, { mtimeMs: st.mtimeMs, size: st.size, note }); else cached.delete(f);
|
|
125
|
+
return note;
|
|
126
|
+
}
|
|
114
127
|
function store(dir, note) { writeAtomic(fileOf(dir, note.id), JSON.stringify(note, null, 1)); }
|
|
115
128
|
const newId = (now) => 'n-' + Math.floor(now).toString(36) + '-' + crypto.randomBytes(2).toString('hex');
|
|
116
129
|
const cleanTitle = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE);
|
|
@@ -147,13 +160,48 @@ function stateFields(n, now, resumeMs) {
|
|
|
147
160
|
ended_at: endedAt, ended_by: endedBy, sessions: sessionsOf(n) };
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
/** The meeting time a row shows: the meeting clock, except that an ended meeting whose recorder ran on for more than
|
|
164
|
+
* LEFT_ON_MS after the last line heard stops at that line ("1735 min" for a stand-up left recording). */
|
|
165
|
+
const LEFT_ON_MS = 10 * 60 * 1000;
|
|
166
|
+
function heardMs(n, now = Date.now()) {
|
|
167
|
+
const ms = meetingMs(n, now);
|
|
168
|
+
if (!n.ended_at && !n.paused_at) return ms;
|
|
169
|
+
const last = Math.max(0, ...(n.transcript || []).map((x) => (x && Number.isFinite(x.t_end) ? x.t_end : x && Number.isFinite(x.t) ? x.t : 0)));
|
|
170
|
+
return last > 0 && ms - last > LEFT_ON_MS ? last : ms;
|
|
171
|
+
}
|
|
172
|
+
/** The series a note belongs to (c3-notes-series): the PM's choice (`series_pm`: a key, or 'off' for none), else the calendar
|
|
173
|
+
* series of its meeting, else the meeting's title, else the note's own title (calendar-memory.js seriesKey and normTitle).
|
|
174
|
+
* "Untitled note" and a title with no letters belong to none. → { key, by: 'pm'|'calendar'|'title' } or null. Pure. */
|
|
175
|
+
function seriesOf(n) {
|
|
176
|
+
if (!n) return null;
|
|
177
|
+
if (n.series_pm === 'off') return null;
|
|
178
|
+
if (typeof n.series_pm === 'string' && SERIES_KEY_RE.test(n.series_pm)) return { key: n.series_pm, by: 'pm' };
|
|
179
|
+
const calMem = require('./calendar-memory.js');
|
|
180
|
+
const m = n.meeting && n.meeting.kind === 'event' ? n.meeting : null;
|
|
181
|
+
if (m && m.context && m.context.series) return { key: 's:' + m.context.series, by: 'calendar' };
|
|
182
|
+
const t = calMem.normTitle((m && m.title) || n.title);
|
|
183
|
+
if (!t || t === 'untitled note' || t === 'untitled') return null;
|
|
184
|
+
return { key: 't:' + t, by: 'title' };
|
|
185
|
+
}
|
|
186
|
+
const SERIES_KEY_RE = /^[st]:[^\n]{1,200}$/;
|
|
187
|
+
/** Take a note out of its series (key 'off'), put it in one (a key), or go back to the automatic series (null). */
|
|
188
|
+
function setSeries(dir, { id, key } = {}) {
|
|
189
|
+
const note = load(dir, id);
|
|
190
|
+
if (!note) return err(404, 'no such note');
|
|
191
|
+
if (key === null || key === undefined || key === '') delete note.series_pm;
|
|
192
|
+
else if (key === 'off' || SERIES_KEY_RE.test(String(key))) note.series_pm = String(key);
|
|
193
|
+
else return err(400, 'that is not a series');
|
|
194
|
+
store(dir, note);
|
|
195
|
+
return { ok: true, id: note.id, series: seriesOf(note) };
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
/** The row a list shows. Never the body or the transcript. */
|
|
151
199
|
function rowOf(n, now = Date.now(), resumeMs = RESUME_MS) {
|
|
152
200
|
return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
|
|
153
201
|
words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
|
|
154
|
-
meeting: n.meeting || null, initiative: n.initiative || null, board: n.board || null, portfolio: !!n.portfolio, ws: n.ws || null,
|
|
202
|
+
meeting: n.meeting || null, initiative: n.initiative || null, board: n.board || null, portfolio: !!n.portfolio, ws: n.ws || null, series: seriesOf(n),
|
|
155
203
|
// the meeting time so far (a paused note's clock) and how many statements differ from a repository file (the Notes menu)
|
|
156
|
-
meeting_ms: n.recorded_from ?
|
|
204
|
+
meeting_ms: n.recorded_from ? heardMs(n, now) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
|
|
157
205
|
// a private note shows its title on the calendar and the initiative, never its summary
|
|
158
206
|
summary_lines: n.private ? [] : summaryLines(n), ...stateFields(n, now, resumeMs) };
|
|
159
207
|
}
|
|
@@ -299,7 +347,8 @@ function list(dir, { now = Date.now(), resumeMs } = {}) {
|
|
|
299
347
|
let names = [];
|
|
300
348
|
try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, notes: [] }; }
|
|
301
349
|
const notes = [];
|
|
302
|
-
for (const f of names) { const n =
|
|
350
|
+
for (const f of names) { const n = loadCached(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n, now, resumeOf(resumeMs))); }
|
|
351
|
+
require('./notes-series.js').assign(notes);
|
|
303
352
|
notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
|
|
304
353
|
return { ok: true, notes };
|
|
305
354
|
}
|
|
@@ -1711,7 +1760,7 @@ function editFinal(dir, { id, sec, i, text, field, now = Date.now() } = {}) {
|
|
|
1711
1760
|
return { ok: true, final_notes: f };
|
|
1712
1761
|
}
|
|
1713
1762
|
|
|
1714
|
-
module.exports = { addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
|
|
1763
|
+
module.exports = { load, loadCached, heardMs, seriesOf, setSeries, SERIES_KEY_RE, addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
|
|
1715
1764
|
wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, stop, expire, stateOf, sessionsOf, cleanContext, RESUME_MS, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
1716
1765
|
summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf,
|
|
1717
1766
|
meetsBar, checkable, vocabOf, termsIn, namesIn, SECTION_CAP, JOIN_GAP_MS,
|