create-pmos 0.63.2 → 0.65.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/guide/notes.md +35 -6
- package/workspace/guide/people.md +19 -10
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/people-cli.js +7 -8
- package/workspace/lib/people-store.js +110 -1
- package/workspace/lib/people.js +74 -50
- package/workspace/lib/routes-notes.js +33 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +82 -0
- package/workspace/public/pm/dest-people.js +13 -27
- package/workspace/public/pm/view-notes.js +228 -19
- package/workspace/server.js +8 -5
- package/workspace/skills/gather-requirements.skill +2 -2
- package/workspace/skills/jira-sync.skill +1 -1
- package/workspace/skills/live-notes.skill +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@bcf4a86
|
|
2
|
+
assembled: 2026-10-07
|
|
3
3
|
mode: vendor
|
|
@@ -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-07T00:08:29Z"}
|
|
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-07T00:08:29Z"}
|
|
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-07T00:08:29Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
package/workspace/guide/notes.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
|
-
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, and get a summary."
|
|
4
|
+
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
|
|
5
5
|
timestamp: 2026-10-06
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -28,9 +28,9 @@ The meeting can only be heard when local transcription is set up. Until then, **
|
|
|
28
28
|
|
|
29
29
|
## What is caught
|
|
30
30
|
|
|
31
|
-
Each line gets a label: decision, action, question, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, statements with a number go under **Facts mentioned**.
|
|
31
|
+
Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
|
|
32
32
|
|
|
33
|
-
- To remove an item that does not belong, press the **x** beside it.
|
|
33
|
+
- To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
|
|
34
34
|
- When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
|
|
35
35
|
|
|
36
36
|
## Set up local transcription
|
|
@@ -57,6 +57,35 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
|
|
|
57
57
|
|
|
58
58
|
With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
|
|
59
59
|
|
|
60
|
+
## Correct and tag a line
|
|
61
|
+
|
|
62
|
+
1. In the transcript, press the **…** button on a line.
|
|
63
|
+
2. Change what you need:
|
|
64
|
+
- **Words**: correct what was heard. The words that were heard are kept, the line shows **edited**, and **Use what was heard** puts them back.
|
|
65
|
+
- **Label**: decision, action, question, fact, topic, statement or small talk.
|
|
66
|
+
- **Summary**: **As picked**, **Always in the summary** or **Never in the summary**.
|
|
67
|
+
- **Tags**: words such as `#pricing #launch`.
|
|
68
|
+
- **People**: names, separated by commas. Press a name below the field to add a person from this repository.
|
|
69
|
+
3. Press **Save**. The tags and people show on the line, on its item in your notes and under **Tagged**. **Copy as Markdown** includes them.
|
|
70
|
+
|
|
71
|
+
## See what changed in your notes
|
|
72
|
+
|
|
73
|
+
Each change to your notes keeps the text from before it. Changes within two minutes count as one. The first text you typed is always kept.
|
|
74
|
+
|
|
75
|
+
1. When your notes have changed, the header shows **Edited**. Press it.
|
|
76
|
+
2. **History** shows each version, newest first: the lines that were added and removed, and the original at the end.
|
|
77
|
+
3. To go back, press **Use this version**. The text before it stays in **History**.
|
|
78
|
+
|
|
79
|
+
## Replay the meeting
|
|
80
|
+
|
|
81
|
+
When a meeting was heard with local transcription, its audio is kept on this machine.
|
|
82
|
+
|
|
83
|
+
1. Open the note. **Replay** is above your notes. Press play.
|
|
84
|
+
2. While it plays, the transcript line, its item and the line of your notes that you wrote at that moment are highlighted. Your notes cannot be edited while it plays: pause to edit them.
|
|
85
|
+
3. Press a time in the transcript to play from there.
|
|
86
|
+
|
|
87
|
+
To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
|
|
88
|
+
|
|
60
89
|
## Dictate into your notes
|
|
61
90
|
|
|
62
91
|
1. Put the cursor where the text must go, then press **Dictate** and speak.
|
|
@@ -66,13 +95,13 @@ With the switches on, each line goes to TypeSafe for its label. For a caught lin
|
|
|
66
95
|
## Copy, private and delete
|
|
67
96
|
|
|
68
97
|
- **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
|
|
69
|
-
- In the settings, **Mark private** stops listening at once. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
98
|
+
- In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
70
99
|
- **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
|
|
71
|
-
- **Delete** removes the note, its transcript and its
|
|
100
|
+
- **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
|
|
72
101
|
|
|
73
102
|
## What stays on your machine
|
|
74
103
|
|
|
75
|
-
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes
|
|
104
|
+
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, until you delete the audio, the note, or mark the note private. The browser's own recognition is used only on the device, never in the cloud.
|
|
76
105
|
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
77
106
|
- Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
|
|
78
107
|
|
|
@@ -12,20 +12,26 @@ People lists the people you work with in the current repository. For each person
|
|
|
12
12
|
|
|
13
13
|
## How people get into People
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
People adds every person it finds. It reads the names the workspace already holds for the repository each time you open People:
|
|
16
16
|
|
|
17
|
-
- **
|
|
18
|
-
- **Live notes
|
|
17
|
+
- **Plan items.** The owner of each item, when the owner is a person.
|
|
18
|
+
- **Live notes.** Each named speaker. A session marked private is skipped.
|
|
19
|
+
- **Meeting notes.** The speakers you renamed and the action owners the summary finds, in your notes for this repository. A note marked private is skipped.
|
|
20
|
+
- **Calendar.** The attendees this machine remembers, but only when **Keep attendee names from the calendar** is on (see Settings below).
|
|
21
|
+
|
|
22
|
+
The workspace does not read your mail, chat or Jira. Agent sessions that already read them with your own connectors record the people they see:
|
|
23
|
+
|
|
24
|
+
- **Gather requirements** records the author of each item it gathers and the people an item names.
|
|
25
|
+
- **Live notes** records each named speaker and each named action owner.
|
|
19
26
|
- **Jira sync** records assignees and reporters by their Jira name.
|
|
20
|
-
- **Morning brief** records meeting attendees, but only when
|
|
27
|
+
- **Morning brief** records meeting attendees, but only when calendar names are on.
|
|
21
28
|
|
|
22
|
-
Each record is a sighting: a name, a handle (a mail address, a chat or Jira name, a
|
|
29
|
+
Each record is a sighting: a name, a handle (a mail address, a chat or Jira name, a speaker, or only a name), and at most one interaction line with a pointer to where it came from. No message body is kept. Private, personal, confidential and HR material is skipped. A numbered speaker such as "Speaker 2" is not a person.
|
|
23
30
|
|
|
24
|
-
A sighting
|
|
31
|
+
A sighting of a new handle adds the person. A sighting with the full name of a person in People joins that person. A first name alone, from a speaker or a name, joins the one person with that first name. When two people have that first name, it joins neither of them.
|
|
25
32
|
|
|
26
33
|
## Waiting for you
|
|
27
34
|
|
|
28
|
-
- **A new person.** Press **Add to People** to add them, or **Ignore** to hide them. Later sightings of an ignored handle stay hidden.
|
|
29
35
|
- **A possible duplicate.** When a session thinks two handles are one person, it says why. Press **Merge** to join the handle to that person, or **Keep separate** to close the question.
|
|
30
36
|
- **Tones to confirm.** The count of people whose tone you have not confirmed. Open the person to confirm it.
|
|
31
37
|
|
|
@@ -39,7 +45,8 @@ Type in **Find a person** to search names, handles and initiatives. **All**, **T
|
|
|
39
45
|
- **Tone.** The mix of tones (warm, neutral, cautious, tense) and a proposed overall tone: the most frequent tone of the latest 10 interactions. When two tones are tied, the latest one wins. Press **Confirm** to keep the proposal, **Set another** to choose a different tone, or **Change** to clear your choice. Tone describes the exchanges, not the person.
|
|
40
46
|
- **Shared work.** The initiatives your interactions name, the plan items the person owns (with **Blocked** when the item is blocked), and the coming meetings that name them when calendar names are on. Nothing here is typed by hand.
|
|
41
47
|
- **Notes.** Type a note and press **Add note**. Press **Remove** to remove one. Only you can add or remove a note. No command writes one.
|
|
42
|
-
- **Identity and sources.** The person's handles, how many interactions each source gave, and the file the person lives in.
|
|
48
|
+
- **Identity and sources.** The person's handles, how many interactions each source gave, where People found them, and the file the person lives in.
|
|
49
|
+
- **Remove from People.** Takes the person out. Later sightings of their handles stay out.
|
|
43
50
|
|
|
44
51
|
## Ways in from other places
|
|
45
52
|
|
|
@@ -65,6 +72,8 @@ People keeps one append-only file, `people.jsonl`, in the repository's state dir
|
|
|
65
72
|
- `pmos people settings` prints `calendar names: on` or `calendar names: off`. The morning brief reads it before it records attendees.
|
|
66
73
|
- `pmos people help` prints the commands.
|
|
67
74
|
|
|
68
|
-
The sightings file holds `{ "sightings": [...], "merges": [...] }`. A sighting is `{ "source": "gather" | "calendar" | "live" | "jira", "handle": { "kind": "mail" | "slack" | "jira" | "confluence" | "speaker" | "name", "value": "…" }, "name": "…", "role"?, "org"?, "external"?: true, "interaction"?: { "date": "YYYY-MM-DD", "kind": "Mail", "text": "one line, at most 200 characters", "pointer": "where it came from, at most 200 characters", "tone"?: "warm", "initiative"?: "<id>" } }`. A merge is `{ "a": <handle>, "b": <handle>, "why": "one line" }`.
|
|
75
|
+
The sightings file holds `{ "sightings": [...], "merges": [...] }`. A sighting is `{ "source": "gather" | "calendar" | "live" | "jira" | "plan" | "notes", "handle": { "kind": "mail" | "slack" | "jira" | "confluence" | "speaker" | "name", "value": "…" }, "name": "…", "role"?, "org"?, "external"?: true, "interaction"?: { "date": "YYYY-MM-DD", "kind": "Mail", "text": "one line, at most 200 characters", "pointer": "where it came from, at most 200 characters", "tone"?: "warm", "initiative"?: "<id>" } }`. A merge is `{ "a": <handle>, "b": <handle>, "why": "one line" }`.
|
|
76
|
+
|
|
77
|
+
`pmos people list` and `pmos people show` first add the plan item owners and Live notes speakers, as People does.
|
|
69
78
|
|
|
70
|
-
No command
|
|
79
|
+
No command removes a person, merges a handle, sets a tone or writes a note. Do those in People.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-audio.js — the meeting audio a note keeps for replay (meeting-notes Run 3, D92). Local only.
|
|
3
|
+
*
|
|
4
|
+
* write(runtimeDir, { id, source, pcm, t }) add one chunk of 16 kHz mono 16-bit samples at meeting time t (ms)
|
|
5
|
+
* info(runtimeDir, id) → { bytes, ms } | null
|
|
6
|
+
* remove(runtimeDir, id) delete the note's audio
|
|
7
|
+
* serve(req, res, runtimeDir, id) the audio as a WAV file, with byte ranges so the page can seek
|
|
8
|
+
* getKeep(runtimeDir) · setKeep(runtimeDir, bool) "Keep the audio for replay", on by default, for this machine
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT KEEPS. One file per note, `<runtime>/notes/recordings/<id>.pcm`: raw samples with no header, sample k at
|
|
11
|
+
* k / 16000 seconds after the note's first Listen. The microphone and the meeting are added into the same samples, so the
|
|
12
|
+
* file is the meeting as the PM heard it. A chunk is placed straight after the previous chunk of its source when it
|
|
13
|
+
* arrives on time or early, so the small jitter of the page's timer leaves no clicks; a chunk that arrives much later (Listen again)
|
|
14
|
+
* is placed at its own time, and the gap stays silent.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
|
|
17
|
+
* private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const RATE = 16000;
|
|
23
|
+
const BYTES_PER_MS = (RATE * 2) / 1000;
|
|
24
|
+
const ON_TIME_MS = 1500; // a chunk this close to where its source left off is placed straight after it
|
|
25
|
+
const MAX_BYTES = RATE * 2 * 60 * 60 * 6; // six hours; a longer meeting keeps its first six hours
|
|
26
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
27
|
+
|
|
28
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
|
|
29
|
+
const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
|
|
30
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
|
|
31
|
+
const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
|
|
32
|
+
|
|
33
|
+
function getKeep(runtimeDir) {
|
|
34
|
+
try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s.keep !== false; } catch { return true; }
|
|
35
|
+
}
|
|
36
|
+
function setKeep(runtimeDir, keep) {
|
|
37
|
+
if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
|
|
38
|
+
const f = settingsFile(runtimeDir);
|
|
39
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
40
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify({ keep }), { mode: 0o600 });
|
|
42
|
+
fs.renameSync(tmp, f);
|
|
43
|
+
return { ok: true, keep };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. */
|
|
47
|
+
function write(runtimeDir, { id, source, pcm, t }) {
|
|
48
|
+
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
49
|
+
const f = fileOf(runtimeDir, id);
|
|
50
|
+
const key = f + '|' + source;
|
|
51
|
+
let at = Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
|
|
52
|
+
const prev = next.get(key);
|
|
53
|
+
// a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
|
|
54
|
+
if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
55
|
+
if (at >= MAX_BYTES) return { ok: true, kept: false };
|
|
56
|
+
const len = Math.min(pcm.length, MAX_BYTES - at);
|
|
57
|
+
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
58
|
+
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
59
|
+
try {
|
|
60
|
+
const old = Buffer.alloc(len);
|
|
61
|
+
fs.readSync(fd, old, 0, len, at);
|
|
62
|
+
const out = Buffer.alloc(len);
|
|
63
|
+
for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
|
|
64
|
+
fs.writeSync(fd, out, 0, len, at);
|
|
65
|
+
} finally { fs.closeSync(fd); }
|
|
66
|
+
next.set(key, at + len);
|
|
67
|
+
return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function info(runtimeDir, id) {
|
|
71
|
+
if (!ID_RE.test(String(id || ''))) return null;
|
|
72
|
+
try { const st = fs.statSync(fileOf(runtimeDir, id)); return st.size ? { bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) } : null; } catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
function remove(runtimeDir, id) {
|
|
75
|
+
if (!ID_RE.test(String(id || ''))) return false;
|
|
76
|
+
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
77
|
+
try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wavHeader(dataBytes) {
|
|
81
|
+
const h = Buffer.alloc(44);
|
|
82
|
+
h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
|
|
83
|
+
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
|
|
84
|
+
h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
|
|
85
|
+
h.write('data', 36); h.writeUInt32LE(dataBytes, 40);
|
|
86
|
+
return h;
|
|
87
|
+
}
|
|
88
|
+
/** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
|
|
89
|
+
function serve(req, res, runtimeDir, id) {
|
|
90
|
+
const inf = info(runtimeDir, id);
|
|
91
|
+
if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
|
|
92
|
+
const header = wavHeader(inf.bytes);
|
|
93
|
+
const total = header.length + inf.bytes;
|
|
94
|
+
let start = 0; let end = total - 1; let partial = false;
|
|
95
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || '').trim());
|
|
96
|
+
if (m && (m[1] || m[2])) {
|
|
97
|
+
if (m[1]) { start = Number(m[1]); if (m[2]) end = Math.min(total - 1, Number(m[2])); }
|
|
98
|
+
else { start = Math.max(0, total - Number(m[2])); }
|
|
99
|
+
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return; }
|
|
100
|
+
partial = true;
|
|
101
|
+
}
|
|
102
|
+
res.writeHead(partial ? 206 : 200, { 'Content-Type': 'audio/wav', 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-store',
|
|
103
|
+
...(partial ? { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total } : {}) });
|
|
104
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
105
|
+
if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
|
|
106
|
+
const from = Math.max(0, start - header.length); const to = end - header.length;
|
|
107
|
+
if (to < from) { res.end(); return; }
|
|
108
|
+
fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { write, info, remove, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1
|
|
2
|
+
/* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 3).
|
|
3
3
|
*
|
|
4
4
|
* list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
|
|
5
5
|
* record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t }) · setPrivate(dir, { id, private })
|
|
6
6
|
* categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
|
|
7
7
|
* updateSegment(dir, { id, index, text, patch }) · setLabel(dir, { id, index, cat }) · renameSpeaker(dir, { id, source, name })
|
|
8
|
+
* editLine(dir, { id, index, cat?, summary?, tags?, people?, text? }) the PM's corrections to one line (Run 3, D92)
|
|
9
|
+
* versionsOf(note) → [{ at, title, body, original }] every kept version of the notes, oldest first (Run 3, D92)
|
|
8
10
|
* dictation(text) → { ops } the cleaned phrase as insert operations, spoken commands resolved
|
|
9
11
|
* cleanSentences(text) → [sentence] a recognised phrase, cleaned and split into sentences
|
|
10
12
|
* summarize(note) → { markdown, sections, counts } the local summary: no model, the same for the same input
|
|
@@ -15,6 +17,13 @@
|
|
|
15
17
|
* never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
|
|
16
18
|
* the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
|
|
17
19
|
*
|
|
20
|
+
* CORRECTIONS AND VERSIONS (Run 3, D92). The PM can correct a line: its label (the six, or fact), whether it is in the
|
|
21
|
+
* summary, its tags, the people it names and its text. A corrected text keeps the first text the transcript recorded as
|
|
22
|
+
* `orig_text`, and its repository check is dropped because it was about the old words. Every save of the notes keeps the
|
|
23
|
+
* state before it in `versions`; a save within VERSION_FOLD_MS of the last kept version keeps nothing new, so a burst of
|
|
24
|
+
* typing is one version, and the first version is never replaced. While a note has been recorded, each new line of the
|
|
25
|
+
* notes gets the meeting time it was first saved at (`line_times`), so a replay can show what was typed when.
|
|
26
|
+
*
|
|
18
27
|
* SPEECH. This file never sees audio. Words reach it from local Whisper (lib/notes-whisper.js, D91) or from the browser's
|
|
19
28
|
* on-device recognition (processLocally, no cloud fallback, D85). A transcript line carries its source: `mic` and
|
|
20
29
|
* `meeting` from Whisper, `speech` from the browser. The note names each source ("You", "Meeting"); the PM can rename it.
|
|
@@ -43,6 +52,10 @@ const MAX_BODY = 400 * 1024;
|
|
|
43
52
|
const MAX_PHRASE = 4000;
|
|
44
53
|
const MAX_SEGMENTS = 20000;
|
|
45
54
|
const MAX_AGENT_SUMMARY = 64 * 1024;
|
|
55
|
+
const VERSION_FOLD_MS = 2 * 60 * 1000;
|
|
56
|
+
const MAX_VERSIONS = 200;
|
|
57
|
+
const MAX_TAGS = 12;
|
|
58
|
+
const MAX_LINE_TIMES = 2000;
|
|
46
59
|
const notesDir = (dir) => path.join(dir, 'notes');
|
|
47
60
|
const handoffDir = (dir) => path.join(notesDir(dir), 'handoff');
|
|
48
61
|
const fileOf = (dir, id) => path.join(notesDir(dir), id + '.json');
|
|
@@ -97,15 +110,51 @@ function read(dir, id) {
|
|
|
97
110
|
function save(dir, { id, title, body, now = Date.now() } = {}) {
|
|
98
111
|
const note = load(dir, id);
|
|
99
112
|
if (!note) return err(404, 'no such note');
|
|
100
|
-
|
|
113
|
+
const nextTitle = title !== undefined ? cleanTitle(title) : note.title;
|
|
114
|
+
let nextBody = note.body;
|
|
101
115
|
if (body !== undefined) {
|
|
102
|
-
|
|
103
|
-
if (Buffer.byteLength(
|
|
104
|
-
|
|
116
|
+
nextBody = String(body == null ? '' : body);
|
|
117
|
+
if (Buffer.byteLength(nextBody) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
|
|
118
|
+
}
|
|
119
|
+
if (nextTitle !== note.title || nextBody !== (note.body || '')) {
|
|
120
|
+
keepVersion(note, now);
|
|
121
|
+
if (nextBody !== (note.body || '')) stampLines(note, nextBody, now);
|
|
105
122
|
}
|
|
123
|
+
note.title = nextTitle; note.body = nextBody;
|
|
106
124
|
note.updated_at = iso(now);
|
|
107
125
|
store(dir, note);
|
|
108
|
-
return { ok: true, updated_at: note.updated_at };
|
|
126
|
+
return { ok: true, updated_at: note.updated_at, versions: (note.versions || []).length };
|
|
127
|
+
}
|
|
128
|
+
/** Keep the note's text before this save, unless the last kept version is younger than VERSION_FOLD_MS. Notes with no
|
|
129
|
+
* text yet are not a version. The first version is never dropped, so the original stays. */
|
|
130
|
+
function keepVersion(note, now) {
|
|
131
|
+
if (!String(note.body || '').trim()) return;
|
|
132
|
+
const vs = note.versions || (note.versions = []);
|
|
133
|
+
const last = vs[vs.length - 1];
|
|
134
|
+
if (last && now - Date.parse(last.kept_at) < VERSION_FOLD_MS) return;
|
|
135
|
+
vs.push({ at: note.body_at || note.updated_at, kept_at: iso(now), title: note.title || '', body: note.body || '' });
|
|
136
|
+
if (vs.length > MAX_VERSIONS) vs.splice(1, 1);
|
|
137
|
+
note.body_at = iso(now);
|
|
138
|
+
}
|
|
139
|
+
/** Each line of the notes that is new in this save gets the meeting time it appeared at. */
|
|
140
|
+
function stampLines(note, body, now) {
|
|
141
|
+
if (!note.recorded_from) return;
|
|
142
|
+
const t = now - Date.parse(note.recorded_from);
|
|
143
|
+
if (!(t >= 0)) return;
|
|
144
|
+
const prev = note.line_times || {};
|
|
145
|
+
const next = {};
|
|
146
|
+
for (const raw of String(body).split(/\r?\n/)) {
|
|
147
|
+
const l = raw.trim();
|
|
148
|
+
if (!l || next[l] !== undefined) continue;
|
|
149
|
+
next[l] = prev[l] !== undefined ? prev[l] : Math.round(t);
|
|
150
|
+
if (Object.keys(next).length >= MAX_LINE_TIMES) break;
|
|
151
|
+
}
|
|
152
|
+
note.line_times = next;
|
|
153
|
+
}
|
|
154
|
+
/** Every kept version, oldest first, then the current text. The first is the original. */
|
|
155
|
+
function versionsOf(note) {
|
|
156
|
+
const vs = (note.versions || []).map((v, i) => ({ at: v.at, title: v.title, body: v.body, original: i === 0 }));
|
|
157
|
+
return vs;
|
|
109
158
|
}
|
|
110
159
|
function remove(dir, id) {
|
|
111
160
|
if (!load(dir, id)) return err(404, 'no such note');
|
|
@@ -176,11 +225,47 @@ function setLabel(dir, { id, index, cat, drop = false, now = Date.now() } = {})
|
|
|
176
225
|
if (!note) return err(404, 'no such note');
|
|
177
226
|
const seg = (note.transcript || [])[index];
|
|
178
227
|
if (!seg) return err(404, 'no such line');
|
|
179
|
-
if (!
|
|
228
|
+
if (!PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
|
|
180
229
|
seg.cat = cat; seg.cat_by = 'pm'; if (drop) seg.dropped = true; else delete seg.dropped; note.updated_at = iso(now);
|
|
181
230
|
store(dir, note);
|
|
182
231
|
return { ok: true, segment: seg };
|
|
183
232
|
}
|
|
233
|
+
const cleanTag = (t) => String(t == null ? '' : t).replace(/^[#\s]+/, '').replace(/\s+/g, '-').replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40);
|
|
234
|
+
const cleanPerson = (p) => String(p == null ? '' : p).replace(/^[@\s]+/, '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
235
|
+
const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = x.toLowerCase(); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
|
|
236
|
+
/** The PM's corrections to one line. Each field is optional; what is not given is kept. */
|
|
237
|
+
function editLine(dir, { id, index, cat, summary, tags, people, text, now = Date.now() } = {}) {
|
|
238
|
+
const note = load(dir, id);
|
|
239
|
+
if (!note) return err(404, 'no such note');
|
|
240
|
+
const seg = (note.transcript || [])[index];
|
|
241
|
+
if (!seg) return err(404, 'no such line');
|
|
242
|
+
if (cat !== undefined && !PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
|
|
243
|
+
if (summary !== undefined && summary !== null && typeof summary !== 'boolean') return err(400, 'summary must be true, false or null');
|
|
244
|
+
if (tags !== undefined && !Array.isArray(tags)) return err(400, 'tags must be a list');
|
|
245
|
+
if (people !== undefined && !Array.isArray(people)) return err(400, 'people must be a list');
|
|
246
|
+
let resummarize = false;
|
|
247
|
+
if (text !== undefined) {
|
|
248
|
+
const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
249
|
+
if (!t) return err(400, 'a line cannot be empty: remove it from its section instead');
|
|
250
|
+
if (t.length > MAX_PHRASE) return err(413, 'that line is too long');
|
|
251
|
+
if (t !== seg.text) {
|
|
252
|
+
if (seg.orig_text === undefined) seg.orig_text = seg.text;
|
|
253
|
+
if (t === seg.orig_text) { delete seg.orig_text; delete seg.edited_at; } else seg.edited_at = iso(now);
|
|
254
|
+
seg.text = t; delete seg.check; resummarize = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (cat !== undefined) { seg.cat = cat; seg.cat_by = 'pm'; delete seg.dropped; resummarize = true; }
|
|
258
|
+
if (summary !== undefined) { if (summary === null) delete seg.in_summary; else seg.in_summary = summary; resummarize = true; }
|
|
259
|
+
if (tags !== undefined) { const v = uniq(tags.map(cleanTag)).slice(0, MAX_TAGS); if (v.length) seg.tags = v; else delete seg.tags; }
|
|
260
|
+
if (people !== undefined) { const v = uniq(people.map(cleanPerson)).slice(0, MAX_TAGS); if (v.length) seg.people = v; else delete seg.people; }
|
|
261
|
+
if (resummarize && note.summary && note.summary.source === 'local') {
|
|
262
|
+
const sm = summarize(note);
|
|
263
|
+
note.summary = { source: 'local', at: iso(now), markdown: sm.markdown, sections: sm.sections };
|
|
264
|
+
}
|
|
265
|
+
note.updated_at = iso(now);
|
|
266
|
+
store(dir, note);
|
|
267
|
+
return { ok: true, segment: seg, summary: note.summary || null };
|
|
268
|
+
}
|
|
184
269
|
function renameSpeaker(dir, { id, source, name, now = Date.now() } = {}) {
|
|
185
270
|
const note = load(dir, id);
|
|
186
271
|
if (!note) return err(404, 'no such note');
|
|
@@ -325,6 +410,8 @@ function isQuestion(s) {
|
|
|
325
410
|
}
|
|
326
411
|
/* ── the local label (Run 2) ───────────────────────────────────────────────────────────────────── */
|
|
327
412
|
const CATS = ['decision', 'action', 'question', 'topic', 'statement', 'small talk'];
|
|
413
|
+
// the labels the PM may set: the six a rule or Jev may give, and fact, which only the PM gives
|
|
414
|
+
const PM_CATS = ['decision', 'action', 'question', 'fact', 'topic', 'statement', 'small talk'];
|
|
328
415
|
const SMALL_TALK = /^(?:(?:ok(?:ay)?|so|and|well|um|uh)[, ]+)*(?:hi|hello|hey|morning|good (?:morning|afternoon|evening)|thanks?(?: you)?|thank you|bye|see you|cheers|can you (?:hear|see) me|you(?:'re| are) (?:on )?mute|one sec(?:ond)?|sorry|ok(?:ay)?|yeah|yes|no|right|cool|great|perfect|sounds good|let me share my screen|is everyone here|let's (?:start|begin|get started))\b/i;
|
|
329
416
|
const TOPIC = /\b(?:let's (?:talk about|move on|discuss|look at|go through|turn to)|(?:the )?main (?:thing|topic|item|point)|agenda|next (?:topic|item)|moving on|today we(?:'re| are| will)|the (?:goal|point|idea|context|background|reason) (?:is|here)|what (?:this|that) means is|to give (?:you )?some context)\b/i;
|
|
330
417
|
/** The local label of one line. Decisions, actions and questions use the summary's own rules, so both agree. */
|
|
@@ -340,7 +427,7 @@ function categoryOf(text, names) {
|
|
|
340
427
|
return 'statement';
|
|
341
428
|
}
|
|
342
429
|
/** A statement worth listing under "Facts mentioned": it has a number, or the repository check found its passage. */
|
|
343
|
-
const isFact = (x) => !x.dropped && x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc));
|
|
430
|
+
const isFact = (x) => !x.dropped && (x.cat === 'fact' || (x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc))));
|
|
344
431
|
const fmtTime = (ms) => {
|
|
345
432
|
if (ms == null || !Number.isFinite(ms)) return null;
|
|
346
433
|
const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60;
|
|
@@ -362,7 +449,8 @@ function bodySentences(body) {
|
|
|
362
449
|
function summarize(note) {
|
|
363
450
|
const tr = ((note && note.transcript) || []).filter((x) => x && x.text);
|
|
364
451
|
const typed = bodySentences(note && note.body);
|
|
365
|
-
const items = tr.map((x, i) => ({ i, text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped
|
|
452
|
+
const items = tr.map((x, i) => ({ i, index: (note.transcript || []).indexOf(x), text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped,
|
|
453
|
+
in_summary: x.in_summary === true ? true : x.in_summary === false ? false : null }));
|
|
366
454
|
for (const s of typed) items.push({ i: items.length, text: s, t: null, src: 'notes' });
|
|
367
455
|
const terms = items.map((x) => [...new Set(termsOf(x.text))]);
|
|
368
456
|
const names = namesIn(items.map((x) => x.text));
|
|
@@ -396,8 +484,11 @@ function summarize(note) {
|
|
|
396
484
|
const k = Math.max(3, Math.min(7, Math.round(pool.length / 12)));
|
|
397
485
|
const chosen = [];
|
|
398
486
|
const jac = (a, b) => { const A = new Set(a); const B = new Set(b); let i = 0; for (const w of A) if (B.has(w)) i++; return i / ((A.size + B.size - i) || 1); };
|
|
487
|
+
// the PM's choice wins: a line put in the summary is in it, a line taken out never is
|
|
488
|
+
for (const x of pool) if (x.in_summary === true) chosen.push(x);
|
|
399
489
|
for (const x of pool.slice().sort((a, b) => score[b.i] - score[a.i] || a.i - b.i)) {
|
|
400
490
|
if (chosen.length >= k || score[x.i] <= 0) break;
|
|
491
|
+
if (x.in_summary === false || chosen.includes(x)) continue;
|
|
401
492
|
if (chosen.some((c) => jac(terms[c.i], terms[x.i]) > 0.5)) continue;
|
|
402
493
|
chosen.push(x);
|
|
403
494
|
}
|
|
@@ -410,7 +501,7 @@ function summarize(note) {
|
|
|
410
501
|
const questions = items.filter((x) => kindOf(x, 'question')).filter(once);
|
|
411
502
|
const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
|
|
412
503
|
const sections = {
|
|
413
|
-
overview: chosen.map((x) => ({ text: x.text, at: cite(x) })),
|
|
504
|
+
overview: chosen.map((x) => (x.src === 'transcript' ? { text: x.text, at: cite(x), index: x.index } : { text: x.text, at: cite(x) })),
|
|
414
505
|
decisions: decisions.map((x) => ({ text: x.text, at: cite(x) })),
|
|
415
506
|
actions: actions.map((x) => ({ text: x.text, at: cite(x), owner: x.owner, due: x.due })),
|
|
416
507
|
questions: questions.map((x) => ({ text: x.text, at: cite(x) })),
|
|
@@ -459,9 +550,11 @@ function toMarkdown(note, { redact = false, summary = true } = {}) {
|
|
|
459
550
|
if (String(note.body || '').trim()) out.push('## Notes', '', ...String(note.body).split(/\r?\n/).map(line), '');
|
|
460
551
|
const sum = note.agent && note.agent.markdown ? note.agent.markdown : note.summary && note.summary.markdown;
|
|
461
552
|
if (summary && sum) out.push('## Summary', '', ...sum.split(/\r?\n/).map(line), '');
|
|
462
|
-
if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text)), '');
|
|
553
|
+
if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text + tagWords(x))), '');
|
|
463
554
|
return { markdown: out.join('\n'), withheld };
|
|
464
555
|
}
|
|
556
|
+
/** " #tag @Person" for a line, or "". */
|
|
557
|
+
const tagWords = (x) => [...(x.tags || []).map((t) => '#' + t), ...(x.people || []).map((p) => '@' + p)].map((w) => ' ' + w).join('');
|
|
465
558
|
function promptFor(handoff, out) {
|
|
466
559
|
return 'Summarize a meeting note for me. Read the file ' + handoff + ' (a PMOS meeting note: my typed notes and a transcript recognised on my machine). '
|
|
467
560
|
+ 'Write the summary in Markdown to the file ' + out + ' with these sections: ### Overview (three to six bullets on what the meeting was about and concluded), '
|
|
@@ -507,5 +600,5 @@ function importAgent(dir, note) {
|
|
|
507
600
|
return true;
|
|
508
601
|
}
|
|
509
602
|
|
|
510
|
-
module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, renameSpeaker, speakerOf, categoryOf, isFact, CATS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
603
|
+
module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
511
604
|
summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* pmos people settings [--json] whether calendar attendee names are kept
|
|
9
9
|
*
|
|
10
10
|
* The same module the People place reads (lib/people-store.js), so a terminal line and the page cannot disagree.
|
|
11
|
-
*
|
|
11
|
+
* `list` and `show` first record the names this repository's records hold (plan item owners, Live notes speakers), as
|
|
12
|
+
* the People place does. No command removes a person, merges, sets a tone or writes a note: those are the PM's presses
|
|
13
|
+
* in People (D90).
|
|
12
14
|
* `record` must name who recorded: the session's PMOS_SESSION token, or `--by <profile>` outside the workspace.
|
|
13
15
|
*
|
|
14
16
|
* Exit codes: 0 done, 1 refused, 2 usage.
|
|
@@ -25,7 +27,7 @@ const USAGE = [
|
|
|
25
27
|
' pmos people show <person> [--json] one person: interactions, tone, shared work, notes and handles',
|
|
26
28
|
' pmos people record --file <sightings.json> (agent session) record the people a session saw',
|
|
27
29
|
' pmos people settings [--json] whether calendar attendee names are kept (calendar names: on|off)',
|
|
28
|
-
'Nothing here
|
|
30
|
+
'Nothing here removes a person, merges, sets a tone or writes a note. Do that in People.',
|
|
29
31
|
];
|
|
30
32
|
const BOOL = new Set(['json', 'help']);
|
|
31
33
|
|
|
@@ -64,20 +66,17 @@ async function run(argv, { say = console.log, cwd = process.cwd(), env = process
|
|
|
64
66
|
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
65
67
|
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
|
|
66
68
|
|
|
69
|
+
if (sub === 'list' || sub === 'show') store.scan(root, { now });
|
|
67
70
|
if (sub === 'list') {
|
|
68
71
|
const v = store.view(root, { now });
|
|
69
72
|
if (!v.ok) return fail(v.error);
|
|
70
73
|
if (json) { say(JSON.stringify({ ok: true, people: v.people, review: v.review, settings: v.settings, counts: v.counts })); return 0; }
|
|
71
74
|
const r = v.review;
|
|
72
|
-
say(`People · ${v.people.length} ${v.people.length === 1 ? 'person' : 'people'} · ${r.
|
|
73
|
-
if (!v.people.length
|
|
75
|
+
say(`People · ${v.people.length} ${v.people.length === 1 ? 'person' : 'people'} · ${r.merges.length} possible duplicate${r.merges.length === 1 ? '' : 's'} · ${r.tones.length} tone${r.tones.length === 1 ? '' : 's'} to confirm`);
|
|
76
|
+
if (!v.people.length) { say('Nobody yet. People adds the owners of plan items, Live notes speakers, and the people agent sessions record.'); return 0; }
|
|
74
77
|
for (const p of v.people) {
|
|
75
78
|
say(`${p.name} (${p.id}) ${[p.role, p.org].filter(Boolean).join(' · ') || p.group} · ${p.count} interaction${p.count === 1 ? '' : 's'}${p.last ? ' · last ' + p.last : ''} · ${toneWord(p.tone)}`);
|
|
76
79
|
}
|
|
77
|
-
if (r.new.length) {
|
|
78
|
-
say('New, waiting for you in People:');
|
|
79
|
-
for (const f of r.new) say(` ${f.name} ${handleText(f.handle)} · ${f.count} sighting${f.count === 1 ? '' : 's'} from ${f.sources.join(', ')}`);
|
|
80
|
-
}
|
|
81
80
|
return 0;
|
|
82
81
|
}
|
|
83
82
|
|