create-pmos 0.61.1 → 0.63.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/first-paint-audit.js +4 -2
- package/workspace/guide/index.md +6 -3
- package/workspace/guide/layout.md +5 -4
- package/workspace/guide/notes.md +72 -0
- package/workspace/guide/people.md +70 -0
- package/workspace/lib/calendar-memory.js +2 -1
- package/workspace/lib/jev-notes.js +162 -0
- package/workspace/lib/notes-repo.js +133 -0
- package/workspace/lib/notes-whisper.js +200 -0
- package/workspace/lib/notes.js +511 -0
- package/workspace/lib/people-cli.js +135 -0
- package/workspace/lib/people-store.js +123 -0
- package/workspace/lib/people.js +399 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +230 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +441 -0
- package/workspace/public/pm/dest-people.js +385 -0
- package/workspace/public/pm/dest-settings.js +44 -3
- package/workspace/public/pm/frame-copy.js +7 -0
- package/workspace/public/pm/initiative-sheet.js +5 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/item-sheet.js +18 -1
- package/workspace/public/pm/panel-cockpit.js +3 -0
- package/workspace/public/pm/people-link.js +106 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/today-agenda.js +3 -1
- package/workspace/public/pm/today-day.js +2 -0
- package/workspace/public/pm/topbar.js +4 -3
- package/workspace/public/pm/view-notes.js +723 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +30 -1
- package/workspace/skills/gather-requirements.skill +5 -3
- package/workspace/skills/jira-sync.skill +5 -4
- package/workspace/skills/live-notes.skill +3 -1
- package/workspace/skills/morning-brief.skill +5 -3
- package/workspace/test-lists/meeting-notes.json +15 -0
- package/workspace/test-lists/people-directory.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.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-06T17:30:54Z"}
|
|
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-06T17:30:54Z"}
|
|
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-06T17:30:54Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -20,13 +20,15 @@
|
|
|
20
20
|
// The budgets start at specimen A's counts. reports: 14 → 15 in Run 6 for Present, the one control the PM asked to keep
|
|
21
21
|
// with the slides (2026-10-06). Every place +1 for Docs in the top bar (the PM, 2026-10-06, docs-three-way-sync): the
|
|
22
22
|
// budget is for the whole screen and the bar is on every screen. The calibration stays specimen A's own count: the
|
|
23
|
-
// specimen did not change.
|
|
23
|
+
// specimen did not change. Every place +1 again for People in the top bar (the PM, 2026-10-06, people-directory).
|
|
24
24
|
const SPECIMEN_A = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
|
|
25
25
|
const BAR_DOCS = 1;
|
|
26
|
+
const BAR_PEOPLE = 1;
|
|
27
|
+
const BAR = BAR_DOCS + BAR_PEOPLE;
|
|
26
28
|
// Agenda opens on the month (the PM's Agenda mockup v5, 2026-10-06): the month adds its own controls to Today's budget,
|
|
27
29
|
// at most six week rows of a week button and seven days, the two month arrows and the five lenses.
|
|
28
30
|
const AGENDA_MONTH = 6 * 8 + 2 + 5;
|
|
29
|
-
const EXPECT = Object.freeze({ today: 17 +
|
|
31
|
+
const EXPECT = Object.freeze({ today: 17 + BAR + AGENDA_MONTH, initiatives: 24 + BAR, board: 33 + BAR, reports: 15 + BAR });
|
|
30
32
|
|
|
31
33
|
/* ── shoot.js audit(), copied verbatim (scratchpad/specimens2/shoot.js, 2026-10-05) ─────────────────────────────── */
|
|
32
34
|
// runs in the page
|
package/workspace/guide/index.md
CHANGED
|
@@ -12,8 +12,8 @@ This guide describes the PMOS workspace as it works today. It ships with the app
|
|
|
12
12
|
|
|
13
13
|
On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
14
14
|
|
|
15
|
-
1. **The top bar.** Repository ▾, the
|
|
16
|
-
2. **The place.** One of Agenda, Initiatives, Board or Reports, or Docs (⌘K or `g d`). It fills the screen under the bar.
|
|
15
|
+
1. **The top bar.** Repository ▾, the six places (Docs and People included), search (⌘K), Sync when the repository is not in sync, and Settings.
|
|
16
|
+
2. **The place.** One of Agenda, Initiatives, Board or Reports, or Docs (⌘K or `g d`), or People (⌘K or `g p`). It fills the screen under the bar.
|
|
17
17
|
3. **The detail.** The item you opened: an initiative with its status, next steps and artifacts, or the profiles. It opens beside the place and closes with ×.
|
|
18
18
|
4. **The agent dock.** Terminals, documents, conversations and web pages. It is open on Agenda and a thin rail elsewhere.
|
|
19
19
|
|
|
@@ -27,7 +27,8 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
27
27
|
| The items of an initiative | Board, or Breakdown in the initiative detail |
|
|
28
28
|
| Run reports | Reports |
|
|
29
29
|
| Whether a document is the same as its Google Docs or Confluence copy | Docs: ⌘K → **Docs**, or press `g d` |
|
|
30
|
-
|
|
|
30
|
+
| The people you work with: who they are, your interactions, their tone, your notes and shared work | People: ⌘K → **People**, or press `g p` |
|
|
31
|
+
| Repositories, Profiles, New sessions (the default CLI and model), Validate consent, People (calendar names), Theme | Settings |
|
|
31
32
|
| Validate | Settings, under Validate consent: **Open Validate** |
|
|
32
33
|
| A document, a decision record, a memory entry, a page of this guide | ⌘K, with its scope: Knowledge, Memory or Guide |
|
|
33
34
|
| A report across every repository | ⌘K: Portfolio report |
|
|
@@ -37,6 +38,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
37
38
|
|
|
38
39
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
39
40
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
|
41
|
+
- [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
|
|
40
42
|
- [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
|
|
41
43
|
- [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
|
|
42
44
|
- [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
|
|
@@ -46,6 +48,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
46
48
|
- [Validate a repository](validate-with-typesafe.md): what each kind of document is compared with, how to read the verdict, and what leaves your machine.
|
|
47
49
|
- [Today and the proactive engine](proactive-engine.md): what Today finds, what you can do with an item, the settings, the commands and the limits.
|
|
48
50
|
- [Live notes](live-notes.md): take notes in a meeting, confirm each talking point, and what goes to Jev when you turn it on.
|
|
51
|
+
- [Notes](notes.md): listen to a meeting on this machine, see decisions, actions and questions caught and checked against the repository, and get a summary.
|
|
49
52
|
- [Find knowledge without reading whole files](knowledge-graph.md): what `pmos kg` finds, its commands, how to read its numbers, and its limits.
|
|
50
53
|
- [Initiatives: the Roadmap, the List and the initiative sheet](initiatives-screen.md): how to read and order the initiatives of a repository, open the sheet, make a new initiative, and review the cycle.
|
|
51
54
|
- [Initiatives and the Roadmap: Now, Next and Later](initiatives-roadmap.md): what day one shows, how an agent proposes a move, and `pmos initiative` and `pmos measure` from a terminal.
|
|
@@ -15,7 +15,7 @@ The top bar has these parts, from left to right:
|
|
|
15
15
|
|
|
16
16
|
- **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
|
|
17
17
|
- **Branches and sessions** is in ⌘K. It opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
|
|
18
|
-
- **Agenda, Initiatives, Board, Reports and
|
|
18
|
+
- **Agenda, Initiatives, Board, Reports, Docs and People** are the six places. Press one to show it. The Agenda count shows how many items need you. Docs shows whether each document is the same as its Google Docs and Confluence copy. People shows the people you work with in the repository ([People](people.md)).
|
|
19
19
|
- **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
|
|
20
20
|
- **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
|
|
21
21
|
- **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
|
|
@@ -25,12 +25,13 @@ Each place is drawn when its run is built. Until then the place says which run b
|
|
|
25
25
|
|
|
26
26
|
## Settings
|
|
27
27
|
|
|
28
|
-
Settings holds settings only, in
|
|
28
|
+
Settings holds settings only, in eight sections:
|
|
29
29
|
|
|
30
30
|
- **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
|
|
31
31
|
- **Profiles**: the Claude profiles a session can start with. They open in the detail.
|
|
32
32
|
- **New sessions**: the default CLI and the default model for a new session in the dock.
|
|
33
33
|
- **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
|
|
34
|
+
- **People**: whether the morning brief keeps attendee names from the calendar for the current repository. It is off until you turn it on. Turning it off also hides the names recorded before.
|
|
34
35
|
- **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. When a sign-in at the address is refused, this section names the check that failed, such as the email or the AUD tag. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
|
|
35
36
|
- **Theme**: Dark or Light.
|
|
36
37
|
- **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, one button shows: **Update to** and the version. It updates every part that is older, in this order:
|
|
@@ -75,7 +76,7 @@ The workspace is neutral. A primary button is the text colour on the page colour
|
|
|
75
76
|
|
|
76
77
|
| Key | What it does |
|
|
77
78
|
|---|---|
|
|
78
|
-
| g t, g i, g b, g r, g d | Show Agenda, Initiatives, Board, Reports or
|
|
79
|
+
| g t, g i, g b, g r, g d, g p | Show Agenda, Initiatives, Board, Reports, Docs or People. |
|
|
79
80
|
| ⌘K (Ctrl-K) | Open or close the command palette. |
|
|
80
81
|
| ⌘J (Ctrl-J) | Open or fold the dock. |
|
|
81
82
|
| ⌘⇧J (Ctrl-Shift-J) | Focus: give the dock the whole window, or show the place again. |
|
|
@@ -126,7 +127,7 @@ The address options `?layout=legacy` and `?fixture=cockpit` keep the older layou
|
|
|
126
127
|
|
|
127
128
|
The rail is 64 pixels wide and shows one tile for each repository. A dot on a tile means that repository has something that needs you. The selected repository has no dot, because the list beside it shows what needs you. The `+` tile adds a repository. The rail has no tools menu: the repository list and Validate are in Settings, and everything else is in ⌘K.
|
|
128
129
|
|
|
129
|
-
Today, Validate, live notes and Add repository open as a tab, not in place of the list. Choose the same one again and the existing tab comes to the front.
|
|
130
|
+
Today, Validate, live notes, notes and Add repository open as a tab, not in place of the list. Choose the same one again and the existing tab comes to the front.
|
|
130
131
|
|
|
131
132
|
Press ⌘K (Ctrl-K) to jump to anything: an initiative, a repository, an open tab, a session or a tool. Type to filter, use the arrow keys to choose, and press Enter. Esc closes it.
|
|
132
133
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
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."
|
|
5
|
+
timestamp: 2026-10-06
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Guide: notes
|
|
9
|
+
|
|
10
|
+
The Notes view is one screen for one meeting. Your own notes are on the left, with the decisions, actions and questions that the workspace catches while people talk. The transcript is on the right. Notes are kept on this machine, outside every repository.
|
|
11
|
+
|
|
12
|
+
## Start a meeting
|
|
13
|
+
|
|
14
|
+
1. Press ⌘K, type **notes** and press Enter. The view opens as a tab. It is the same in every repository.
|
|
15
|
+
2. Press **New meeting**. Type a title. Type your own notes in the box below it; they steer the summary. The note saves itself when you stop typing.
|
|
16
|
+
3. Press **All notes** to open an earlier note. Press the settings button for local transcription, Jev, private and delete.
|
|
17
|
+
|
|
18
|
+
## Listen
|
|
19
|
+
|
|
20
|
+
1. Press **Listen**. Select what to listen to:
|
|
21
|
+
- **My microphone**: what you say.
|
|
22
|
+
- **The meeting**: the other people in an online meeting. Select the meeting's tab or window next, and tick **Share tab audio**.
|
|
23
|
+
2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
|
|
24
|
+
3. Each line shows in the transcript a few seconds after it is said, with who said it, the time and its label. Your lines are **You**. The meeting's lines are **Meeting**: click the name to change it. The transcript cannot tell the people in the meeting apart.
|
|
25
|
+
4. Press **Stop** to end. The summary shows at the top of your notes.
|
|
26
|
+
|
|
27
|
+
The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
|
|
28
|
+
|
|
29
|
+
## What is caught
|
|
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**.
|
|
32
|
+
|
|
33
|
+
- To remove an item that does not belong, press the **x** beside it.
|
|
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
|
+
|
|
36
|
+
## Set up local transcription
|
|
37
|
+
|
|
38
|
+
Local transcription uses whisper.cpp, a free program that runs on your machine. A Mac with Apple silicon (M1 or later) and 8 GB of memory is enough. The model is about 1.6 GB on disk and uses about 3 GB of memory while it runs.
|
|
39
|
+
|
|
40
|
+
1. Open the settings and press **Set up in a terminal**. A terminal opens with the setup commands typed in. They install whisper.cpp with Homebrew and download the model into the workspace's own folder.
|
|
41
|
+
2. Read the commands and press Enter in the terminal. The workspace does not run them for you.
|
|
42
|
+
3. When the download is done, open the settings again. It says **Whisper is set up**.
|
|
43
|
+
|
|
44
|
+
## Use Jev for labels and checks
|
|
45
|
+
|
|
46
|
+
Without Jev, the workspace labels each line with its own rules and finds the closest file by shared words. Jev labels better and can say whether a line agrees with the file.
|
|
47
|
+
|
|
48
|
+
1. Save a TypeSafe key and switch on validation for the repository (see [Validate with TypeSafe](validate-with-typesafe.md)).
|
|
49
|
+
2. Open the settings and switch on **Label lines with Jev**. Switch on **Check notes against this repository with Jev** for the checks too. Both are off until you switch them on, for each repository.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
## Dictate into your notes
|
|
54
|
+
|
|
55
|
+
1. Put the cursor where the text must go, then press **Dictate** and speak.
|
|
56
|
+
2. Say **new line** or **new paragraph** to start one. Say **scratch that** to remove the phrase you just said.
|
|
57
|
+
3. Press **Stop dictating** when you are done.
|
|
58
|
+
|
|
59
|
+
## Copy, private and delete
|
|
60
|
+
|
|
61
|
+
- **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
|
|
62
|
+
- 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.
|
|
63
|
+
- **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.
|
|
64
|
+
- **Delete** removes the note, its transcript and its summaries from this machine after you confirm. You cannot undo it.
|
|
65
|
+
|
|
66
|
+
## What stays on your machine
|
|
67
|
+
|
|
68
|
+
- 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 the file at once. The browser's own recognition is used only on the device, never in the cloud.
|
|
69
|
+
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
70
|
+
- Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
|
|
71
|
+
|
|
72
|
+
Live notes is a different tool: it maps each talking point to an initiative. See [Live notes](live-notes.md).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "People: the people you work with in a repository, their interactions, tone, notes and shared work"
|
|
4
|
+
timestamp: 2026-10-06
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# People
|
|
8
|
+
|
|
9
|
+
Open People from the top bar, from ⌘K (**People**), or press `g p`.
|
|
10
|
+
|
|
11
|
+
People lists the people you work with in the current repository. For each person it shows who they are, your interactions with them and where each one came from, the tone of those interactions, your own notes, and the work you share.
|
|
12
|
+
|
|
13
|
+
## How people get into People
|
|
14
|
+
|
|
15
|
+
The workspace does not read your mail, chat, calendar or Jira. Agent sessions that already read them with your own connectors record the people they see:
|
|
16
|
+
|
|
17
|
+
- **Gather requirements** records the author of each item it gathers.
|
|
18
|
+
- **Live notes** records each named speaker.
|
|
19
|
+
- **Jira sync** records assignees and reporters by their Jira name.
|
|
20
|
+
- **Morning brief** records meeting attendees, but only when **Keep attendee names from the calendar** is on (see Settings below).
|
|
21
|
+
|
|
22
|
+
Each record is a sighting: a name, a handle (a mail address, a chat or Jira name, a transcript speaker, or only a name), and at most one interaction line in the agent's words with a pointer to where it came from. No message body is kept. Private, personal, confidential and HR material is skipped.
|
|
23
|
+
|
|
24
|
+
A sighting does not make a person. New people wait for you in **Waiting for you** at the top of People.
|
|
25
|
+
|
|
26
|
+
## Waiting for you
|
|
27
|
+
|
|
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
|
+
- **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
|
+
- **Tones to confirm.** The count of people whose tone you have not confirmed. Open the person to confirm it.
|
|
31
|
+
|
|
32
|
+
## The list
|
|
33
|
+
|
|
34
|
+
Type in **Find a person** to search names, handles and initiatives. **All**, **Team** and **External** filter the list. **Latest**, **Most** and **Name** sort it by the last interaction, the number of interactions or the name. Press a person to open their page beside the list.
|
|
35
|
+
|
|
36
|
+
## A person's page
|
|
37
|
+
|
|
38
|
+
- **Interactions.** Newest first. Each line shows the date, the kind (Mail, Chat, Meeting, 1:1, Jira, Doc, Call or Mention), the line, and where it came from with its pointer and tone.
|
|
39
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
43
|
+
|
|
44
|
+
## Ways in from other places
|
|
45
|
+
|
|
46
|
+
- **Agenda.** A meeting's detail shows a link for each person its attendee line names.
|
|
47
|
+
- **Initiative sheet.** The **People** tab lists the people whose interactions name the initiative or who own one of its items.
|
|
48
|
+
- **Item sheet.** When an item's owner is a person in People, the owner is a link to their page.
|
|
49
|
+
|
|
50
|
+
A link matches a full name, a first name of three letters or more, or a handle, as a whole word. Two people with the same first name both match.
|
|
51
|
+
|
|
52
|
+
## Settings
|
|
53
|
+
|
|
54
|
+
Settings has a **People** section for the current repository. **Keep attendee names from the calendar** is off until you turn it on. While it is off, the morning brief records nobody from the calendar, `pmos people record` refuses calendar sightings, and names recorded before stay hidden.
|
|
55
|
+
|
|
56
|
+
## Where People keeps its records
|
|
57
|
+
|
|
58
|
+
People keeps one append-only file, `people.jsonl`, in the repository's state directory (`pmos/state/` in a product repository). Sync sends it with your other records, so People is the same on every machine that syncs the repository. Every clone of the repository can read your tone choices and notes. Keep them to work context.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
- `pmos people list` lists each person with their role, interactions and tone, and the new people waiting for you. Add `--json` for the whole view.
|
|
63
|
+
- `pmos people show <person>` shows one person's page: interactions, tone, shared work, notes and handles. `<person>` is the id from the list or the full name.
|
|
64
|
+
- `pmos people record --file <sightings.json>` records the people a session saw. Agent sessions run it. It must name who recorded it: in a workspace session the session token does this; outside the workspace, add `--by <profile>`. A sighting already recorded is skipped and counted.
|
|
65
|
+
- `pmos people settings` prints `calendar names: on` or `calendar names: off`. The morning brief reads it before it records attendees.
|
|
66
|
+
- `pmos people help` prints the commands.
|
|
67
|
+
|
|
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" }`.
|
|
69
|
+
|
|
70
|
+
No command adds a person, ignores or merges a handle, sets a tone or writes a note. Do those in People.
|
|
@@ -90,6 +90,7 @@ function validateMemory(raw, { now = Date.now() } = {}) {
|
|
|
90
90
|
if (e.series !== undefined && !str(e.series, 200)) return { ok: false, error: `${at}: series is a text of at most 200 characters` };
|
|
91
91
|
if (e.attendees !== undefined && !(Number.isInteger(e.attendees) && e.attendees >= 0 && e.attendees <= 1000)) return { ok: false, error: `${at}: attendees is a whole number from 0 to 1000` };
|
|
92
92
|
for (const b of ['external', 'exec']) if (e[b] !== undefined && typeof e[b] !== 'boolean') return { ok: false, error: `${at}: ${b} is true or false` };
|
|
93
|
+
if (e.with !== undefined && !str(e.with, 120)) return { ok: false, error: `${at}: with is one line of at most 120 characters` };
|
|
93
94
|
if (e.role !== undefined && !ROLES.includes(e.role)) return { ok: false, error: `${at}: role is one of ${ROLES.join(', ')}` };
|
|
94
95
|
const past = f <= now;
|
|
95
96
|
if (e.notes !== undefined) {
|
|
@@ -107,7 +108,7 @@ function validateMemory(raw, { now = Date.now() } = {}) {
|
|
|
107
108
|
if (ids.has(id)) return { ok: false, error: `${at}: two events have the same start and title` };
|
|
108
109
|
ids.add(id);
|
|
109
110
|
const out = { id, start: new Date(s).toISOString(), end: new Date(f).toISOString(), title };
|
|
110
|
-
for (const k of ['series', 'attendees', 'external', 'exec', 'role', 'notes', 'open', 'prep', 'prep_done']) if (e[k] !== undefined) out[k] = e[k];
|
|
111
|
+
for (const k of ['series', 'attendees', 'external', 'exec', 'role', 'with', 'notes', 'open', 'prep', 'prep_done']) if (e[k] !== undefined) out[k] = e[k];
|
|
111
112
|
events.push(out);
|
|
112
113
|
}
|
|
113
114
|
events.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jev-notes.js — the one bridge from the Notes view to Jev (meeting-notes Run 2, D91).
|
|
3
|
+
*
|
|
4
|
+
* gatesFor(key, flags) · checkGatesFor(key, flags) · senderFor(key, flags, askOpts) · infoFor(present, flags)
|
|
5
|
+
* label({ ask, key, askOpts, text, pause }) → { cat, p } | { cat: null, reason }
|
|
6
|
+
* compare({ ask, key, askOpts, text, passage, pause }) → { verdict: 'agrees'|'differs'|'related', words } | { verdict: null, reason }
|
|
7
|
+
* getSwitch(runtimeDir, root) · setSwitch(runtimeDir, root, { jev, check })
|
|
8
|
+
*
|
|
9
|
+
* WHAT MAY LEAVE THE MACHINE. For a label: one cleaned transcript line and one Choice question. For a check: the line and
|
|
10
|
+
* ONE repository passage (lib/notes-repo.js closest, at most 1,200 characters) and two Noul questions. Only through
|
|
11
|
+
* lib/typesafe.js. Labels need three gates: a saved key, D79 consent for the repository and the switch "notes may use Jev".
|
|
12
|
+
* A check needs those and the switch "check notes against this repository". Both switches default off and live in the
|
|
13
|
+
* per-user runtime directory. Never sent: audio, a speaker's name, a line or passage the secret scan matches, a private
|
|
14
|
+
* note (the caller refuses it before this module), anything while paused.
|
|
15
|
+
*
|
|
16
|
+
* WHAT A RESULT IS. Advisory (D40), shown in words. A label under UNSURE_BELOW keeps the local label. A check says
|
|
17
|
+
* "agrees" or "differs" only above SURE; otherwise "related", which is what the local match alone also says.
|
|
18
|
+
*
|
|
19
|
+
* PAUSE. Two rejected keys or three failed asks in a row pause Jev for the repository until the server restarts or the
|
|
20
|
+
* PM turns a switch off and on again; the local labels carry on.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const typesafe = require('./typesafe');
|
|
25
|
+
const scanner = require('./secret-scan');
|
|
26
|
+
|
|
27
|
+
const UNSURE_BELOW = 0.55;
|
|
28
|
+
const SURE = 0.65;
|
|
29
|
+
const REJECTED_LIMIT = 2;
|
|
30
|
+
const UNREACHABLE_LIMIT = 3;
|
|
31
|
+
const NOTES_ASK = { timeoutMs: 10000, retries: 1, backoffMs: 400 };
|
|
32
|
+
|
|
33
|
+
const CATS = {
|
|
34
|
+
decision: 'A decision: something the group agreed, chose, approved or ruled out.',
|
|
35
|
+
action: 'An action item: a task someone will do, or is asked to do, often with an owner or a due day.',
|
|
36
|
+
question: 'An open question the meeting raises and has not answered.',
|
|
37
|
+
topic: 'A topic explanation: it introduces or explains what is being discussed, or moves to a new subject.',
|
|
38
|
+
statement: 'A statement of fact, data, a claim or an opinion about the work.',
|
|
39
|
+
'small talk': 'Small talk, a greeting, thanks, or talk about the meeting itself.',
|
|
40
|
+
};
|
|
41
|
+
const LABEL_INSTRUCTIONS = 'This is one line from a meeting transcript. Which kind of line is it?';
|
|
42
|
+
const SAME_Q = 'Does the PASSAGE from the repository already say what the MEETING LINE says?';
|
|
43
|
+
const DIFF_Q = 'Does the MEETING LINE say something different from, or in conflict with, the PASSAGE from the repository (for example another number, date, owner or choice)?';
|
|
44
|
+
|
|
45
|
+
/* ── gates and switches ─────────────────────────────────────────────────────────────────────────── */
|
|
46
|
+
function gates({ key, consent, jev }) {
|
|
47
|
+
const missing = [];
|
|
48
|
+
if (!key) missing.push('key');
|
|
49
|
+
if (!consent) missing.push('consent');
|
|
50
|
+
if (!jev) missing.push('jev');
|
|
51
|
+
return { open: missing.length === 0, missing };
|
|
52
|
+
}
|
|
53
|
+
function checkGates({ key, consent, jev, check }) {
|
|
54
|
+
const g = gates({ key, consent, jev });
|
|
55
|
+
if (!check) g.missing.push('check');
|
|
56
|
+
return { open: g.missing.length === 0, missing: g.missing };
|
|
57
|
+
}
|
|
58
|
+
const GATE_WORDS = {
|
|
59
|
+
key: 'No TypeSafe key is saved.',
|
|
60
|
+
consent: 'Validation is not switched on for this repository.',
|
|
61
|
+
jev: 'Notes may not use Jev in this repository.',
|
|
62
|
+
check: 'Checking notes against this repository is off.',
|
|
63
|
+
};
|
|
64
|
+
const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
|
|
65
|
+
const gatesFor = (key, f) => gates({ key: typesafe.validKey(key), consent: !!(f && f.consent), jev: !!(f && f.jev) });
|
|
66
|
+
const checkGatesFor = (key, f) => checkGates({ key: typesafe.validKey(key), consent: !!(f && f.consent), jev: !!(f && f.jev), check: !!(f && f.check) });
|
|
67
|
+
|
|
68
|
+
/** { ask, check, askOpts }: `ask` sends labels and `check` is true when checks may also be sent. Both shut when a gate is. */
|
|
69
|
+
function senderFor(key, flags, askOpts = {}) {
|
|
70
|
+
const g = gatesFor(key, flags);
|
|
71
|
+
if (!g.open) return { ask: null, check: false, gates: g, askOpts: {} };
|
|
72
|
+
return { ask: (a) => typesafe.ask(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
73
|
+
}
|
|
74
|
+
/** What the view needs to explain the two switches. Booleans and sentences only. */
|
|
75
|
+
function infoFor(present, flags) {
|
|
76
|
+
const f = { key: !!present, consent: !!(flags && flags.consent), jev: !!(flags && flags.jev), check: !!(flags && flags.check) };
|
|
77
|
+
const l = gates(f); const c = checkGates(f);
|
|
78
|
+
return { jev: f.jev, check: f.check, label_open: l.open, check_open: c.open, missing: c.missing,
|
|
79
|
+
sentence: l.open ? (c.open ? null : gateSentence(c.missing.filter((m) => m === 'check'))) : gateSentence(l.missing) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
83
|
+
const switchFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'settings.json');
|
|
84
|
+
function getSwitch(runtimeDir, root) {
|
|
85
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
86
|
+
const r = s && s.repos && root ? s.repos[root] : null;
|
|
87
|
+
return { jev: !!(r && r.jev === true), check: !!(r && r.check === true) };
|
|
88
|
+
}
|
|
89
|
+
function setSwitch(runtimeDir, root, { jev, check } = {}) {
|
|
90
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
91
|
+
const prev = getSwitch(runtimeDir, root);
|
|
92
|
+
const next = { jev: typeof jev === 'boolean' ? jev : prev.jev, check: typeof check === 'boolean' ? check : prev.check };
|
|
93
|
+
const f = switchFile(runtimeDir);
|
|
94
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
95
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify({ repos: { ...((s && s.repos) || {}), [root]: next } }), { mode: 0o600 });
|
|
97
|
+
fs.renameSync(tmp, f);
|
|
98
|
+
pauses.delete(root);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── pause ─────────────────────────────────────────────────────────────────────────────────────── */
|
|
103
|
+
const pauses = new Map(); // root → { rejected, unreachable, paused }
|
|
104
|
+
const pauseOf = (root) => { if (!pauses.has(root)) pauses.set(root, { rejected: 0, unreachable: 0, paused: null }); return pauses.get(root); };
|
|
105
|
+
function note(p, res) {
|
|
106
|
+
if (res.ok) { p.rejected = 0; p.unreachable = 0; return; }
|
|
107
|
+
if (res.status === 401) { p.rejected++; p.unreachable = 0; } else { p.unreachable++; p.rejected = 0; }
|
|
108
|
+
if (p.rejected >= REJECTED_LIMIT) p.paused = 'The service rejected the key twice, so Jev is paused for notes.';
|
|
109
|
+
else if (p.unreachable >= UNREACHABLE_LIMIT) p.paused = 'The service could not be reached three times in a row, so Jev is paused for notes.';
|
|
110
|
+
}
|
|
111
|
+
const secretIn = (...texts) => texts.some((t) => scanner.scan(String(t || '')).length > 0);
|
|
112
|
+
|
|
113
|
+
/* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
|
|
114
|
+
function choiceProbs(ans) {
|
|
115
|
+
const probs = {};
|
|
116
|
+
const put = (k, v) => { const n = Number(v); if (typeof k === 'string' && Number.isFinite(n)) probs[k.toLowerCase()] = n; };
|
|
117
|
+
if (!ans || typeof ans !== 'object') return probs;
|
|
118
|
+
const raw = ans.probabilities || ans.distribution;
|
|
119
|
+
if (Array.isArray(raw)) { for (const x of raw) if (x && typeof x === 'object') put(x.option || x.label || x.choice, x.probability != null ? x.probability : x.p); }
|
|
120
|
+
else if (raw && typeof raw === 'object') for (const [k, v] of Object.entries(raw)) put(k, v);
|
|
121
|
+
if (!Object.keys(probs).length && typeof ans.choice === 'string') put(ans.choice, ans.confidence != null ? ans.confidence : 1);
|
|
122
|
+
return probs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line's label from Jev. The caller has checked the private flag; this checks the pause and the scan. */
|
|
126
|
+
async function label({ ask, key, askOpts = {}, text, root = '' }) {
|
|
127
|
+
const p = pauseOf(root);
|
|
128
|
+
if (!ask) return { cat: null, reason: 'off' };
|
|
129
|
+
if (p.paused) return { cat: null, reason: 'paused', sentence: p.paused };
|
|
130
|
+
if (secretIn(text)) return { cat: null, reason: 'withheld' };
|
|
131
|
+
const res = await ask({ key, state: String(text).slice(0, 2000), questions: { kind: { type: 'choice', instructions: LABEL_INSTRUCTIONS, criteria: CATS } }, ...askOpts });
|
|
132
|
+
note(p, res);
|
|
133
|
+
if (!res.ok) return { cat: null, reason: 'failed', sentence: res.reason || null };
|
|
134
|
+
const probs = choiceProbs(res.answers && res.answers.kind);
|
|
135
|
+
const top = Object.entries(probs).filter(([k]) => CATS[k]).sort((a, b) => b[1] - a[1])[0];
|
|
136
|
+
if (!top || top[1] < UNSURE_BELOW) return { cat: null, reason: 'unsure' };
|
|
137
|
+
return { cat: top[0], p: top[1], model: res.model || null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const wordsFor = (v) => (v >= 0.85 ? 'very likely' : 'likely');
|
|
141
|
+
/** One captured line against one passage. */
|
|
142
|
+
async function compare({ ask, key, askOpts = {}, text, passage, root = '' }) {
|
|
143
|
+
const p = pauseOf(root);
|
|
144
|
+
if (!ask) return { verdict: null, reason: 'off' };
|
|
145
|
+
if (p.paused) return { verdict: null, reason: 'paused', sentence: p.paused };
|
|
146
|
+
if (!passage || !passage.text) return { verdict: null, reason: 'nothing' };
|
|
147
|
+
if (secretIn(text, passage.text)) return { verdict: null, reason: 'withheld' };
|
|
148
|
+
const state = 'MEETING LINE:\n' + String(text).slice(0, 2000) + '\n\nPASSAGE (' + passage.path + '):\n' + String(passage.text).slice(0, 1200);
|
|
149
|
+
const res = await ask({ key, state, questions: { same: { type: 'noul', instructions: SAME_Q }, differs: { type: 'noul', instructions: DIFF_Q } }, ...askOpts });
|
|
150
|
+
note(p, res);
|
|
151
|
+
if (!res.ok) return { verdict: null, reason: 'failed', sentence: res.reason || null };
|
|
152
|
+
const num = (a) => { const v = a && (a.type == null || a.type === 'noul') ? Number(a.noul) : NaN; return Number.isFinite(v) && v >= 0 && v <= 1 ? v : null; };
|
|
153
|
+
const same = num(res.answers && res.answers.same); const diff = num(res.answers && res.answers.differs);
|
|
154
|
+
if (same == null && diff == null) return { verdict: null, reason: 'unreadable' };
|
|
155
|
+
if (diff != null && diff >= SURE && (same == null || diff > same)) return { verdict: 'differs', words: wordsFor(diff), model: res.model || null };
|
|
156
|
+
if (same != null && same >= SURE) return { verdict: 'agrees', words: wordsFor(same), model: res.model || null };
|
|
157
|
+
return { verdict: 'related', words: null, model: res.model || null };
|
|
158
|
+
}
|
|
159
|
+
function pauseStatus(root) { const p = pauses.get(root); return p && p.paused ? p.paused : null; }
|
|
160
|
+
|
|
161
|
+
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, label, compare, choiceProbs, pauseStatus,
|
|
162
|
+
CATS, UNSURE_BELOW, SURE, REJECTED_LIMIT, UNREACHABLE_LIMIT };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-repo.js — find the repository passage a meeting line is about (meeting-notes Run 2, D91). Local and read-only.
|
|
3
|
+
*
|
|
4
|
+
* passages(root, { exclude }) → [{ path, line, text }] the repository's Markdown paragraphs, cached for a minute
|
|
5
|
+
* closest(root, text, { exclude }) → { path, line, text, shared } | null
|
|
6
|
+
*
|
|
7
|
+
* WHAT IT READS. Every tracked-looking Markdown file under the repository root, skipping dependency, build and hidden
|
|
8
|
+
* folders, at most MAX_FILES files and MAX_FILE_BYTES each. A paragraph is a run of non-blank lines outside frontmatter
|
|
9
|
+
* and code fences, cut to PASSAGE_MAX characters. An excluded path (D79 exclusions) is never read.
|
|
10
|
+
*
|
|
11
|
+
* HOW IT MATCHES. Content words and numbers shared with the line, each weighted by how rare it is across the paragraphs.
|
|
12
|
+
* A passage counts only when it shares at least MIN_SHARED words and scores at least MIN_SCORE, so a line about nothing
|
|
13
|
+
* in particular finds nothing. Numbers count as words: "a 14-day trial" and "21 days" are what a consistency check is for.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT NEVER DOES. It writes nothing and sends nothing. The Jev check (lib/jev-notes.js) decides what leaves.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const settings = require('./typesafe-settings');
|
|
20
|
+
|
|
21
|
+
const MAX_FILES = 3000;
|
|
22
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
23
|
+
const PASSAGE_MAX = 1200;
|
|
24
|
+
const MIN_SHARED = 3;
|
|
25
|
+
const MIN_SCORE = 3;
|
|
26
|
+
const CACHE_MS = 60000;
|
|
27
|
+
const SKIP = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', 'out', 'coverage', '.next', 'tmp', 'payload']);
|
|
28
|
+
|
|
29
|
+
const STOP = new Set(('the and for are but not you all any can had her was one our out has have this that with from they will would there their what '
|
|
30
|
+
+ 'about which when make like time just know take into year your some could them than then look only come its over think also back after use '
|
|
31
|
+
+ 'two how work first well way even want because these give most should does did done been being were yeah okay right really thing things '
|
|
32
|
+
+ 'going gonna said say says very much more less here where who why let lets get got need needs maybe sure good great thanks thank '
|
|
33
|
+
+ 'today week next last new old same other each every into onto upon per via yes no').split(/\s+/));
|
|
34
|
+
const NUM_WORDS = { one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8', nine: '9', ten: '10', eleven: '11', twelve: '12',
|
|
35
|
+
thirteen: '13', fourteen: '14', fifteen: '15', sixteen: '16', seventeen: '17', eighteen: '18', nineteen: '19', twenty: '20', thirty: '30', forty: '40', fifty: '50' };
|
|
36
|
+
const stem = (w) => {
|
|
37
|
+
const x = w.replace(/'s$/, '');
|
|
38
|
+
if (/(?:ing|ed|es)$/.test(x)) return x.replace(/(?:ing|ed|es)$/, (s) => (x.length - s.length >= 4 ? '' : s));
|
|
39
|
+
return /[^s]s$/.test(x) && x.length >= 4 ? x.slice(0, -1) : x;
|
|
40
|
+
};
|
|
41
|
+
/** Content words and numbers. "twenty-one" is 21; "14-day" is 14 and day. */
|
|
42
|
+
function termsOf(text) {
|
|
43
|
+
const t = String(text || '').toLowerCase().replace(/\b(twenty|thirty|forty|fifty)[- ](one|two|three|four|five|six|seven|eight|nine)\b/g,
|
|
44
|
+
(m, a, b) => String(Number(NUM_WORDS[a]) + Number(NUM_WORDS[b])));
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const raw of t.match(/[\p{L}\p{N}']+/gu) || []) {
|
|
47
|
+
const w = NUM_WORDS[raw] || raw;
|
|
48
|
+
if (/^\d+$/.test(w)) { out.push(w); continue; }
|
|
49
|
+
if (w.length < 3 || STOP.has(w)) continue;
|
|
50
|
+
out.push(stem(w));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(root, exclude) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const go = (dir) => {
|
|
58
|
+
if (out.length >= MAX_FILES) return;
|
|
59
|
+
let ents = [];
|
|
60
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
61
|
+
for (const e of ents) {
|
|
62
|
+
if (out.length >= MAX_FILES) return;
|
|
63
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
64
|
+
const p = path.join(dir, e.name);
|
|
65
|
+
const rel = path.relative(root, p).split(path.sep).join('/');
|
|
66
|
+
if (settings.isExcluded(exclude, rel)) continue;
|
|
67
|
+
if (e.isDirectory()) go(p);
|
|
68
|
+
else if (e.isFile() && /\.md$/i.test(e.name)) out.push(rel);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
go(root);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function paragraphsOf(rel, text) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const lines = String(text).split(/\r?\n/);
|
|
77
|
+
let i = 0;
|
|
78
|
+
if (lines[0] === '---') { i = 1; while (i < lines.length && lines[i] !== '---') i++; i++; }
|
|
79
|
+
let fence = false; let buf = []; let start = 0;
|
|
80
|
+
const end = () => { if (buf.length) { const t = buf.join(' ').replace(/\s+/g, ' ').trim(); if (t.length >= 30) out.push({ path: rel, line: start + 1, text: t.slice(0, PASSAGE_MAX) }); } buf = []; };
|
|
81
|
+
for (; i < lines.length; i++) {
|
|
82
|
+
const l = lines[i];
|
|
83
|
+
if (/^\s*(```|~~~)/.test(l)) { end(); fence = !fence; continue; }
|
|
84
|
+
if (fence) continue;
|
|
85
|
+
if (!l.trim() || /^#{1,6}\s/.test(l) || /^\s*\|?\s*-{3,}/.test(l)) { end(); continue; }
|
|
86
|
+
if (!buf.length) start = i;
|
|
87
|
+
buf.push(l.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, ''));
|
|
88
|
+
}
|
|
89
|
+
end();
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cache = new Map(); // root → { at, key, items, df }
|
|
94
|
+
function index(root, { exclude = [], now = Date.now() } = {}) {
|
|
95
|
+
const key = JSON.stringify(exclude);
|
|
96
|
+
const hit = cache.get(root);
|
|
97
|
+
if (hit && hit.key === key && now - hit.at < CACHE_MS) return hit;
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const rel of walk(root, exclude)) {
|
|
100
|
+
let text = '';
|
|
101
|
+
try { const st = fs.statSync(path.join(root, rel)); if (st.size > MAX_FILE_BYTES) continue; text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
|
|
102
|
+
for (const p of paragraphsOf(rel, text)) items.push({ ...p, terms: new Set(termsOf(p.text)) });
|
|
103
|
+
}
|
|
104
|
+
const df = new Map();
|
|
105
|
+
for (const it of items) for (const w of it.terms) df.set(w, (df.get(w) || 0) + 1);
|
|
106
|
+
const ix = { at: now, key, items, df };
|
|
107
|
+
cache.set(root, ix);
|
|
108
|
+
return ix;
|
|
109
|
+
}
|
|
110
|
+
function passages(root, opts = {}) { return index(root, opts).items.map(({ path: p, line, text }) => ({ path: p, line, text })); }
|
|
111
|
+
|
|
112
|
+
/** The passage that shares the most rare words with `text`, or null when none shares enough. */
|
|
113
|
+
function closest(root, text, { exclude = [], now = Date.now() } = {}) {
|
|
114
|
+
if (!root) return null;
|
|
115
|
+
const ix = index(root, { exclude, now });
|
|
116
|
+
const q = new Set(termsOf(text));
|
|
117
|
+
if (q.size < MIN_SHARED) return null;
|
|
118
|
+
const n = ix.items.length || 1;
|
|
119
|
+
let best = null;
|
|
120
|
+
for (const it of ix.items) {
|
|
121
|
+
let s = 0; const shared = [];
|
|
122
|
+
// a shared word counts 1, more when it is rare in the repository; a long passage is not favoured for its length
|
|
123
|
+
for (const w of q) if (it.terms.has(w)) { s += 1 + Math.log(n / (ix.df.get(w) || 1)); shared.push(w); }
|
|
124
|
+
if (shared.length < MIN_SHARED || s < MIN_SCORE) continue;
|
|
125
|
+
const rank = s / Math.sqrt(it.terms.size / 20 + 1);
|
|
126
|
+
if (!best || rank > best.rank) best = { path: it.path, line: it.line, text: it.text, shared, score: s, rank };
|
|
127
|
+
}
|
|
128
|
+
if (!best) return null;
|
|
129
|
+
return { path: best.path, line: best.line, text: best.text, shared: best.shared };
|
|
130
|
+
}
|
|
131
|
+
function clearCache() { cache.clear(); }
|
|
132
|
+
|
|
133
|
+
module.exports = { passages, closest, termsOf, paragraphsOf, clearCache, PASSAGE_MAX, MIN_SHARED, MIN_SCORE };
|