@gotcos/glasses-server 6.63.1 → 6.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/CHANGELOG.md +38 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +11 -0
- package/package.json +2 -1
- package/server/index.ts +7 -0
- package/server/lib/cos-operations-meetings.ts +175 -0
- package/server/lib/jev.ts +5 -3
- package/server/lib/meeting-context-keys.ts +135 -0
- package/server/lib/meeting-store.ts +22 -0
- package/server/lib/task-dates.ts +369 -0
- package/server/lib/task-store.ts +30 -3
- package/server/lib/work-search.ts +226 -0
- package/server/routes/jev.ts +6 -1
- package/server/routes/meetings.ts +57 -6
- package/server/routes/tasks.ts +3 -1
- package/server/routes/work-board.ts +7 -2
- package/server/routes/work-search.ts +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## 6.65.0 — 2026-10-07 — Work search by meaning, and task dates
|
|
2
|
+
|
|
3
|
+
- `POST /api/work/search` `{ query, domain?, scope?, ids? }` finds the Work card the user means, not only the words they typed. Pairs with COS Control 0.5.259, which shows its own word matches at once and asks this after a pause.
|
|
4
|
+
- One Jev Choice per search over the board's own cards, plus `none`. The client names a view or exact card ids, never task text. A domain board searches that domain's cards, open and completed. All work searches open cards only, although Control's All work view also shows completed ones. Completed searches completed cards. Needs attention and In progress send their card ids.
|
|
5
|
+
- What goes to TypeSafe: each card's task text, on one line, at most 300 characters, without `[Work details](…)` links. URLs, email addresses and opaque ids (the shape of a key or a document id) are removed, credentials are masked by the server's secret redaction, and a markdown link keeps only its label. A bare domain such as bottlepos.com stays.
|
|
6
|
+
- The query is 2 to 200 characters, counted in code points, so 150 emoji are 150.
|
|
7
|
+
- The answer is `{ available: true, results: [{ id, p }], none, cached, tokens }`: cards at p 0.02 or more, most likely first, at most 20.
|
|
8
|
+
- When it cannot search it answers HTTP 200 with `{ available: false, reason, message }`. The reasons are `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (Jev refused the request: a 400, 413 or 422), `jev_unavailable` (no answer, a 5xx, or a malformed answer), `search_off` and `too_many_candidates`. A Choice holds 254 cards, so a bigger set is refused, never cut; Completed across all domains is 198 cards today. Control keeps its word search in every case. The server logs each reason at most once per 10 minutes.
|
|
9
|
+
- Its own cost controls, apart from Work intake and session advice. A daily cap of 1,000,000 input tokens (`COS_JEV_SEARCH_DAILY_TOKENS`), about 110 to 200 searches by board size, with its own ledger (`<data>/jev-search-usage.json`); it resets at midnight UTC. Its own breaker: 3 failures, then an hour; a refused request does not count, because the same cards would be refused again. A switch, `COS_JEV_SEARCH=0`. Both come from the server's environment, so changing them takes a restart. Saving a new TypeSafe key clears this breaker too. `COS_JEV_DAILY_TOKENS` is unchanged.
|
|
10
|
+
- The same query over the same cards is answered from memory for 10 minutes (up to 200 searches).
|
|
11
|
+
- Search is read-only, so it does not take the server's request lease: an update or restart never waits on a search, and a search during one is answered rather than refused.
|
|
12
|
+
- Measured on 2026-10-06 on the live board (293 cards, 95 open): 4 searches, the right card first at p 0.99 for each of 3 real queries, `none` at 1 for an unrelated one, 4,815 to 4,963 input tokens and 268 to 431 ms each including the board read. The repeat came from the cache with no call.
|
|
13
|
+
- `GET /api/tasks` and `GET /api/work-board` rows carry `createdOn` (`YYYY-MM-DD` or null), `createdFrom` (`source`, `git` or null) and `lineChangedAt` (ISO time or null), for Control's date orders. Control reads `/api/work-board` for its Work board, so both routes carry them. Additive: every existing field and the task revision are unchanged, and the fields are present as null when unknown, so a client can tell this server from an older one.
|
|
14
|
+
- `createdOn` is the first date in the task's source label, usually the meeting date. Otherwise it is the day the task's words first appeared in the git history of its `tasks.md`, cached on disk (`<data>/task-created-dates.json`, at most 5,000) by file and a hash of the words. A lookup that times out or fails is not cached as "not in history"; it is tried again after 10 minutes.
|
|
15
|
+
- `lineChangedAt` is when git last changed the task's line (blame, once per HEAD and file change). A line that is not committed yet is null, never "now". tasks.md is committed by the COS sync every one to three days, so this is later than the real change.
|
|
16
|
+
- Every read of either route, the phone's and the glasses' included, keeps all of its git work inside 1.5 s, half the phone's 4 s timeout less an allowance for the board read. Past that the dates still unknown come back null and the git work finishes in the background for a later read.
|
|
17
|
+
- Read-only: nothing is written into tasks.md. Git runs only when a `.git` exists at or above the `tasks.md` folder. A public install keeps its tasks outside any repository, so no git process starts. If the home folder is itself a git repository, git does run, finds `tasks.md` untracked, and the git fields stay null. Both the COS bridge and the packaged task runtime get the fields.
|
|
18
|
+
- Measured on 2026-10-06 on the live board (293 cards, a read-only scratch run): 241 dated from the source label, 52 from git, none without a date; 278 with `lineChangedAt` (the other 15 are not committed yet). The first call took 1.2 s (68 git lookups), later calls about 20 ms, and a restart 74 ms with no git history lookup.
|
|
19
|
+
|
|
20
|
+
## 6.64.0 — 2026-10-06 — Meeting context keys
|
|
21
|
+
|
|
22
|
+
- Meeting rows and details carry `contextKeys`, `contextSupported`, and, when files can't be added, `contextReason` (plus `contextAmbiguous`). The fields are additive and read-only. They are the keys COS Control 0.5.258 files a meeting's dropped screenshots, slides and documents under, so the files go to every Work card linked to the meeting.
|
|
23
|
+
- The keys are read from ids the meeting already carries: `g2:<sessionId>` from its `.g2-chunks.json` (or the server's own recordings copy), `ff:<id>` from its `.fireflies.json` (the top-level id, in the first 4 KB), then `g2:<id>` for every `<!-- g2-session -->` it declares.
|
|
24
|
+
- Nothing is written into a meeting. Identity is an exact id, never a title or a date.
|
|
25
|
+
- Two live meetings whose own sidecars give the same primary key are `ambiguous`. A merged scribe that only declares its captures never counts as a second claimant, so a merge whose captures are not yet retired is not ambiguous.
|
|
26
|
+
- Files also can't be added to:
|
|
27
|
+
- an iCloud conflict copy (`conflict_copy`);
|
|
28
|
+
- a meeting with no source id (`no_source_id`);
|
|
29
|
+
- an imported, re-derived or direct-library record (`read_only_record`).
|
|
30
|
+
- A standalone recording is keyed by its session, and its detail keeps its old shape (no `librarySource`).
|
|
31
|
+
- `GET /api/meetings/context-keys?recordId=…` (1 to 50 ids, read-only) answers the keys behind a Work card's saved meeting links.
|
|
32
|
+
- A `standalone:` id gives its session.
|
|
33
|
+
- An `ops:` id is found at its path, after the G2 rename (same month, by the date and time in the filename), or under the same name in another domain of the same month.
|
|
34
|
+
- Two candidates give no answer.
|
|
35
|
+
- Control asks before each send, so a card finds the files whichever record id it was linked under.
|
|
36
|
+
- The primary-key index reads only sidecar heads (4 KB), never a meeting body. It is rebuilt per month when that folder changes, and at least once a minute. Measured on 2026-10-06: 649 of 656 meetings from August to October carry a key; an earlier build of the index, which also read meeting bodies, took 376 ms cold and 1 ms warm over the whole tree on the author's Mac (a read-only scratch script).
|
|
37
|
+
- Pairs with COS Control 0.5.258. Older clients ignore the fields.
|
|
38
|
+
|
|
1
39
|
## 6.63.1 — 2026-10-06 — Weather survives a restart
|
|
2
40
|
|
|
3
41
|
- Home weather survives a server restart. The last good weather and place are kept in `<data>/welcome-weather.json`; when a forecast lookup fails (timeout, HTTP error, bad payload), the route answers with that saved weather if it is under 3 hours old, never older. A failed forecast or place lookup is now logged once per kind per 10 minutes, with the cause and whether a saved weather was served. Before, after a restart the first failed lookup sent the glasses no weather and nothing said why. Pairs with glasses 6.10.603, which also retries on Home and keeps its own last good weather.
|
package/docs/work-intake.md
CHANGED
|
@@ -35,6 +35,8 @@ Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{
|
|
|
35
35
|
|
|
36
36
|
Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
|
|
37
37
|
|
|
38
|
+
Since 6.65.0 `POST /api/work/search` takes `{ query, domain?, scope?, ids? }`: a query of 2 to 200 characters (code points), an optional domain, an optional scope (`all`, `attention`, `progress` or `completed`) and optional card ids (12 hex). It asks Jev one Choice over the board's own cards, plus `none`, and answers `{ available: true, results: [{ id, p }], none, cached, tokens }` with cards at p 0.02 or more, most likely first, at most 20. Exact ids win (Control sends them for Needs attention and In progress). Otherwise a domain board covers that domain's cards, All work its open cards, and Completed its completed cards. Each card goes to TypeSafe as its task text without `[Work details](…)` links, URLs, email addresses or opaque ids, with credentials masked by the server's secret redaction, at most 300 characters. A search that cannot run answers 200 with `{ available: false, reason, message }`: `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (a 400, 413 or 422 from Jev; not counted toward the breaker), `jev_unavailable`, `search_off` (`COS_JEV_SEARCH=0`) or `too_many_candidates` (more than 254 cards). Errors: 400 `invalid_search_request`, 503 `work_board_unavailable`. Search has its own daily cap (`COS_JEV_SEARCH_DAILY_TOKENS`, default 1,000,000, reset at midnight UTC), ledger (`data/jev-search-usage.json`) and breaker, so it never spends the cap Work intake and session advice share; the switch and the cap are read from the environment set at start, so a change takes a restart. The same query over the same cards is answered from memory for 10 minutes. The route is read-only and does not take the server's request lease.
|
|
39
|
+
|
|
38
40
|
Since 6.58.2 `POST /api/query-jobs` also takes an optional `sessionName` (a string; the server cleans it as the 6.58.2 changelog says and keeps at most 100 characters; anything else is no name, never an error). It names only a Claude session the job starts itself (`claude --name=<name>`, when the installed CLI lists `--name`); a Continue, a dispatch, a Codex, Cursor or Ollama job, and an older server ignore it, and it is not part of the request fingerprint. A Claude job's snapshot carries `cliSessionId` from Claude's first stream event while the job is still `running`, not only at completion. COS Control 0.5.250 sends the Work item's title for New session, Start work and Fork to Claude, and links its receipt as soon as the running job names the session.
|
|
39
41
|
|
|
40
42
|
Since 6.59.0 `GET /api/work-board/activity` also projects each handoff's progress (the report, its evidence of at most 280 characters, the newest move), the task's own revision and a saved destination, `GET /api/work-board/activity/open` lists every open handoff across the board, and `POST /api/work-board/handoff-requests` lets the glasses ask COS Control to start Work, reply, or say it is not done yet, which Control claims on this Mac and sends with its own guards. The server runs no provider and no Jev for any of it. See `docs/work-handoff-requests.md`.
|
|
@@ -30,6 +30,17 @@ single-command child pins its domain as cwd instead of relying on unsupported
|
|
|
30
30
|
atomic replacement writes use exclusive no-follow temporary files. Same-user
|
|
31
31
|
modification of the installed package/manifest is outside this integrity boundary.
|
|
32
32
|
|
|
33
|
+
Since 6.65.0 `GET /api/tasks` and `GET /api/work-board` rows also carry `createdOn`,
|
|
34
|
+
`createdFrom` and `lineChangedAt`, for both the COS bridge and this runtime. The server reads them; the
|
|
35
|
+
runtime does not. `createdOn` is the first `YYYY-MM-DD` in the source label, else the
|
|
36
|
+
day the task's words first appeared in the git history of its `tasks.md`.
|
|
37
|
+
`lineChangedAt` is the git blame time of a committed line. Each is null when unknown,
|
|
38
|
+
including every git-derived value when no git repository tracks the `tasks.md`.
|
|
39
|
+
All git work for one read (rev-parse, blame, first-seen lookups) shares a 1.5 s
|
|
40
|
+
deadline; what is still unknown then comes back null, and the git work finishes in the
|
|
41
|
+
background for a later read. Git runs only when a `.git` exists at or above the
|
|
42
|
+
`tasks.md` folder. Nothing is written into `tasks.md`.
|
|
43
|
+
|
|
33
44
|
The default lock namespace remains `~/Library/Application Support/COS/.task_locks.json`
|
|
34
45
|
and canonical `file:<domain>` entries. `COS_TASK_LOCK_STORE` is an explicit override
|
|
35
46
|
for isolated fixtures. Existing COS bridge calls keep their original environment
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.65.0",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"!server/scripts/work-review-mutation-gate.mjs",
|
|
47
47
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
48
48
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
49
|
+
"!server/scripts/work-search-mutation-gate.mjs",
|
|
49
50
|
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/nemotron-mutation-gate.mjs",
|
|
51
52
|
"!server/scripts/merged-scribe-mutation-gate.mjs",
|
package/server/index.ts
CHANGED
|
@@ -142,6 +142,8 @@ import { createWorkBoardRouter } from './routes/work-board.js'
|
|
|
142
142
|
import { createWorkIntakeRouter } from './routes/work-intake.js'
|
|
143
143
|
import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
|
|
144
144
|
import { createJevRouter } from './routes/jev.js'
|
|
145
|
+
import { createWorkSearchRouter } from './routes/work-search.js'
|
|
146
|
+
import { isReadOnlyApiPost } from './lib/work-search.js'
|
|
145
147
|
import { dataPath } from './lib/data-dir.js'
|
|
146
148
|
import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
|
|
147
149
|
import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
|
|
@@ -324,6 +326,9 @@ app.use('/api', requireApiToken(API_TOKEN))
|
|
|
324
326
|
// through their true terminal boundary.
|
|
325
327
|
app.use('/api', (req, res, next) => {
|
|
326
328
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
|
|
329
|
+
// 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
|
|
330
|
+
// lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
|
|
331
|
+
if (isReadOnlyApiPost(req.method, req.path)) return next()
|
|
327
332
|
// V2 video original chunks are bounded to the advertised session size (1 MiB
|
|
328
333
|
// on new sessions, 256 KiB on leftover drafts) and commit through the upload
|
|
329
334
|
// registry's own generation lock. Holding the global mutation lease while
|
|
@@ -756,6 +761,8 @@ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
|
756
761
|
// Jev key (Control Settings), Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews
|
|
757
762
|
// (6.57.1), and the Work completion check (6.58.0).
|
|
758
763
|
app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
|
|
764
|
+
// Work search by meaning (6.65.0, Control 0.5.259): one Jev Choice over the board's cards, with its own cap, switch and breaker.
|
|
765
|
+
app.use('/api', createWorkSearchRouter())
|
|
759
766
|
app.use('/api', queryRouter)
|
|
760
767
|
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
761
768
|
// every other settings route; the brief itself is an ordinary durable job.
|
|
@@ -20,6 +20,7 @@ import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
|
20
20
|
import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
|
|
21
21
|
import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
|
|
22
22
|
import { dataPath } from './data-dir.js'
|
|
23
|
+
import { firefliesSidecarId, meetingContextFields, meetingContextKeys, type MeetingContextFields } from './meeting-context-keys.js'
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The four domains of ONE user's COS. Retained as the documented example layout
|
|
@@ -743,6 +744,102 @@ export function resolveMergedScribe(
|
|
|
743
744
|
return { status: 'resolved', ...only, path, content }
|
|
744
745
|
}
|
|
745
746
|
|
|
747
|
+
// ── Meeting context keys (6.64.0) ───────────────────────────────────────────
|
|
748
|
+
//
|
|
749
|
+
// What a meeting's dropped files are filed under in COS Control. See `meeting-context-keys.ts` for why the key is read
|
|
750
|
+
// from source ids and never written. Two live meetings with the same PRIMARY key are ambiguous, so the list and the
|
|
751
|
+
// detail both need to know how many meetings claim each primary key across every domain: that index is kept per month
|
|
752
|
+
// folder and recomputed only when the folder's own stamp moves (every writer of meetings renames into place, so a new or
|
|
753
|
+
// renamed meeting moves it). A warm read is one stat per month folder.
|
|
754
|
+
|
|
755
|
+
/** The ordered keys for one canonical meeting, from what sits beside it and what it declares. */
|
|
756
|
+
function meetingKeysFor(monthDir: string, filename: string, content: string | null, recordingsRoot: string | null, month: string): string[] {
|
|
757
|
+
let sessionId = sidecarListHints(monthDir, filename).sessionId
|
|
758
|
+
if (!sessionId && recordingsRoot) {
|
|
759
|
+
const fallbackDir = join(recordingsRoot, month)
|
|
760
|
+
if (existsSync(fallbackDir)) sessionId = sidecarListHints(fallbackDir, filename).sessionId
|
|
761
|
+
}
|
|
762
|
+
const firefliesId = firefliesSidecarId(monthDir, filename)
|
|
763
|
+
// The file is read for its markers only when neither sidecar names it: the primary key is then the first it declares.
|
|
764
|
+
let declared: string[] = []
|
|
765
|
+
if (content !== null) declared = mergedScribeSessions(content)
|
|
766
|
+
else if (!sessionId && !firefliesId) {
|
|
767
|
+
try { declared = mergedScribeSessions(readFileSync(join(monthDir, filename), 'utf-8')) } catch { declared = [] }
|
|
768
|
+
}
|
|
769
|
+
return meetingContextKeys({ g2SessionId: sessionId, firefliesId, declared })
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
interface PrimaryKeyMonth { stamp: string; primaries: string[]; builtAt: number }
|
|
773
|
+
const primaryKeyCache = new Map<string, PrimaryKeyMonth>()
|
|
774
|
+
let primaryKeyReads = 0
|
|
775
|
+
/** A month is re-read at least this often even when its folder's stamp has not moved: a sidecar rewritten in place
|
|
776
|
+
* leaves the folder alone (QA 2026-10-06). One month re-read costs about 3 ms. */
|
|
777
|
+
const PRIMARY_KEY_MAX_AGE_MS = 60_000
|
|
778
|
+
|
|
779
|
+
/** The primary key a meeting's OWN sidecars give it, or undefined. A meeting that only declares `<!-- g2-session -->`
|
|
780
|
+
* markers (a merged scribe with no sidecar) claims nothing here: it holds those captures, and counting it would make
|
|
781
|
+
* every merge whose captures are not yet retired read as two meetings fighting over one key (QA 2026-10-06). It also
|
|
782
|
+
* means the index never reads a meeting body, so a cold build costs 4 KB heads only. */
|
|
783
|
+
function sidecarPrimaryKey(monthDir: string, filename: string, recordingsRoot: string | null, month: string): string | undefined {
|
|
784
|
+
let sessionId = sidecarListHints(monthDir, filename).sessionId
|
|
785
|
+
if (!sessionId && recordingsRoot) {
|
|
786
|
+
const fallbackDir = join(recordingsRoot, month)
|
|
787
|
+
if (existsSync(fallbackDir)) sessionId = sidecarListHints(fallbackDir, filename).sessionId
|
|
788
|
+
}
|
|
789
|
+
return meetingContextKeys({ g2SessionId: sessionId, firefliesId: firefliesSidecarId(monthDir, filename) })[0]
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** How many live, canonical meetings take each key as their PRIMARY key from their own sidecars, across every domain. */
|
|
793
|
+
export function meetingPrimaryKeyClaims(operationsDir: string, recordingsRoot: string | null = dataPath('recordings')): Map<string, number> {
|
|
794
|
+
const claims = new Map<string, number>()
|
|
795
|
+
for (const domain of discoverMeetingDomains(operationsDir)) {
|
|
796
|
+
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
797
|
+
let months: string[]
|
|
798
|
+
try { months = readdirSync(meetingsBase).filter(name => MONTH_PATTERN.test(name)) } catch { continue }
|
|
799
|
+
for (const month of months) {
|
|
800
|
+
const monthDir = join(meetingsBase, month)
|
|
801
|
+
let stamp: string
|
|
802
|
+
try { stamp = statStamp(statSync(monthDir)) } catch { primaryKeyCache.delete(monthDir); continue }
|
|
803
|
+
let entry = primaryKeyCache.get(monthDir)
|
|
804
|
+
if (!entry || entry.stamp !== stamp || Date.now() - entry.builtAt > PRIMARY_KEY_MAX_AGE_MS) {
|
|
805
|
+
let names: string[] = []
|
|
806
|
+
try {
|
|
807
|
+
names = readdirSync(monthDir).filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name)).slice(0, MAX_LIST_CANDIDATES)
|
|
808
|
+
} catch { names = [] }
|
|
809
|
+
const primaries: string[] = []
|
|
810
|
+
for (const name of names) {
|
|
811
|
+
primaryKeyReads++
|
|
812
|
+
const key = sidecarPrimaryKey(monthDir, name, recordingsRoot, month)
|
|
813
|
+
if (key) primaries.push(key)
|
|
814
|
+
}
|
|
815
|
+
entry = { stamp, primaries, builtAt: Date.now() }
|
|
816
|
+
primaryKeyCache.set(monthDir, entry)
|
|
817
|
+
}
|
|
818
|
+
for (const key of entry.primaries) claims.set(key, (claims.get(key) ?? 0) + 1)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return claims
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Diagnostics and the cost tests. */
|
|
825
|
+
export function meetingPrimaryKeyCacheStats(): { months: number; reads: number } {
|
|
826
|
+
return { months: primaryKeyCache.size, reads: primaryKeyReads }
|
|
827
|
+
}
|
|
828
|
+
export function resetMeetingPrimaryKeyCache(): void {
|
|
829
|
+
primaryKeyCache.clear()
|
|
830
|
+
primaryKeyReads = 0
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** A meeting's context fields, given its keys and the claims index. The index counts meetings whose OWN sidecar gives
|
|
834
|
+
* the key; a markers-only scribe is not in it, so beside its one live capture it reads 1 (fine), and beside two live
|
|
835
|
+
* copies of that capture it reads 2: its key is contested, so it is ambiguous too. */
|
|
836
|
+
function contextFieldsFor(filename: string, keys: string[], claims: Map<string, number>): MeetingContextFields {
|
|
837
|
+
return meetingContextFields(keys, {
|
|
838
|
+
conflictCopy: ICLOUD_CONFLICT_COPY.test(filename),
|
|
839
|
+
claimants: keys.length > 0 ? claims.get(keys[0]) ?? 1 : 1,
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
746
843
|
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
747
844
|
recordId?: string
|
|
748
845
|
librarySource?: 'direct_library' | 'cos_operations'
|
|
@@ -799,6 +896,7 @@ export function listCosOperationsMeetings(options: {
|
|
|
799
896
|
: discovered.includes(domainFilter) ? [domainFilter] : []
|
|
800
897
|
|
|
801
898
|
const allMeetings: CosOperationsMeetingMeta[] = []
|
|
899
|
+
let claims: Map<string, number> | undefined
|
|
802
900
|
|
|
803
901
|
for (const domain of domains) {
|
|
804
902
|
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
@@ -857,6 +955,11 @@ export function listCosOperationsMeetings(options: {
|
|
|
857
955
|
if (actionId) meta.actionId = actionId
|
|
858
956
|
}
|
|
859
957
|
if (options.day && meta.date !== options.day) continue
|
|
958
|
+
// 6.64.0: the keys this meeting's dropped files are filed under, from the ids just read (no extra read but
|
|
959
|
+
// the Fireflies sidecar's 4 KB head).
|
|
960
|
+
const firefliesId = firefliesSidecarId(monthDir, file)
|
|
961
|
+
const keys = meetingContextKeys({ g2SessionId: hints.sessionId, firefliesId, declared })
|
|
962
|
+
Object.assign(meta, contextFieldsFor(file, keys, claims ??= meetingPrimaryKeyClaims(operationsDir, recordingsRoot)))
|
|
860
963
|
allMeetings.push(meta)
|
|
861
964
|
} catch { /* skip unreadable files */ }
|
|
862
965
|
}
|
|
@@ -1013,9 +1116,14 @@ export function getCosOperationsMeetingDetail(
|
|
|
1013
1116
|
meta.mutable = true
|
|
1014
1117
|
meta.canonicalRecord = `operations/${domain}/meetings/${month}/${resolvedFilename}`
|
|
1015
1118
|
const source = boundedMeetingSource(content)
|
|
1119
|
+
// 6.64.0: the same keys the list row carries, so Control files a drop from either surface under one key.
|
|
1120
|
+
const recordingsRoot = dataPath('recordings')
|
|
1121
|
+
const keys = meetingKeysFor(monthDir, resolvedFilename, content, recordingsRoot, month)
|
|
1122
|
+
const context = contextFieldsFor(resolvedFilename, keys, meetingPrimaryKeyClaims(operationsDir, recordingsRoot))
|
|
1016
1123
|
|
|
1017
1124
|
return {
|
|
1018
1125
|
...meta,
|
|
1126
|
+
...context,
|
|
1019
1127
|
summary: extractSummary(content),
|
|
1020
1128
|
topics: extractTopics(content),
|
|
1021
1129
|
decisions: extractDecisions(content),
|
|
@@ -1026,6 +1134,73 @@ export function getCosOperationsMeetingDetail(
|
|
|
1026
1134
|
}
|
|
1027
1135
|
}
|
|
1028
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* 6.64.0: the context keys a Work card's saved meeting link answers to, without reading the meeting's body.
|
|
1139
|
+
*
|
|
1140
|
+
* WHY. A card links a meeting by record id, and a meeting's record id changes over its life: a fresh capture is
|
|
1141
|
+
* `standalone:<sessionId>`, sync files it as `ops:<domain>:<month>:<file>`, the G2 enrichment renames the file, and a
|
|
1142
|
+
* refile moves it to another domain. The files Miles dropped are filed under the meeting's KEY, which does not change.
|
|
1143
|
+
* COS Control asks here for the keys of every link a card carries, so a card finds its meetings' files whichever record
|
|
1144
|
+
* id it was linked under (QA, 2026-10-06).
|
|
1145
|
+
*
|
|
1146
|
+
* Exact ids only, as everywhere in this module: a `standalone:` id names its session; an `ops:` id is found at its own
|
|
1147
|
+
* path, then by the rename rule the detail route already uses (the date and time in a G2 filename, in the same month),
|
|
1148
|
+
* then by the SAME filename in another domain of the same month (a domain move keeps the name). Two candidates are no
|
|
1149
|
+
* answer. Imported, re-derived and direct-library records are read-only.
|
|
1150
|
+
*/
|
|
1151
|
+
export function meetingContextForRecord(recordId: string, recordingsRoot: string | null = dataPath('recordings')): MeetingContextFields & { resolvedRecordId?: string } {
|
|
1152
|
+
const standalone = /^standalone:([A-Za-z0-9_-]{3,96})$/.exec(recordId)
|
|
1153
|
+
const operationsDir = resolveCosOperationsDir()
|
|
1154
|
+
if (standalone) {
|
|
1155
|
+
// A capture two operations meetings both carry is contested here too (QA round 2, N3).
|
|
1156
|
+
const keys = meetingContextKeys({ g2SessionId: standalone[1] })
|
|
1157
|
+
const claimants = operationsDir && keys.length > 0 ? meetingPrimaryKeyClaims(operationsDir, recordingsRoot).get(keys[0]) ?? 1 : 1
|
|
1158
|
+
return meetingContextFields(keys, { claimants })
|
|
1159
|
+
}
|
|
1160
|
+
const ops = /^ops:([^:/\\]+):(\d{4}-\d{2}):([^/\\]+\.md)$/.exec(recordId)
|
|
1161
|
+
if (!ops || !operationsDir) return { contextKeys: [], contextSupported: false, contextReason: 'read_only_record' }
|
|
1162
|
+
const [, domain, month, filename] = ops
|
|
1163
|
+
const domains = discoverMeetingDomains(operationsDir)
|
|
1164
|
+
if (!safeName(domain) || !MONTH_PATTERN.test(month) || basename(filename) !== filename) {
|
|
1165
|
+
return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
|
|
1166
|
+
}
|
|
1167
|
+
const at = (d: string, f: string) => join(operationsDir, d, 'meetings', month, f)
|
|
1168
|
+
const isFile = (path: string) => { try { return statSync(path).isFile() } catch { return false } }
|
|
1169
|
+
let found: { domain: string; filename: string } | null = domains.includes(domain) && isFile(at(domain, filename)) ? { domain, filename } : null
|
|
1170
|
+
if (!found) {
|
|
1171
|
+
const heads = (d: string) => {
|
|
1172
|
+
try {
|
|
1173
|
+
return readdirSync(join(operationsDir, d, 'meetings', month))
|
|
1174
|
+
.filter(name => name.endsWith('.md') && !ICLOUD_CONFLICT_COPY.test(name))
|
|
1175
|
+
.slice(0, MAX_LIST_CANDIDATES)
|
|
1176
|
+
.map(name => ({ filename: name, content: safeRegularFile(realpathSync(join(operationsDir, d, 'meetings', month)), join(operationsDir, d, 'meetings', month, name), 4_000) ?? '' }))
|
|
1177
|
+
} catch { return [] }
|
|
1178
|
+
}
|
|
1179
|
+
// Renamed in its own domain, then the same name in another domain, then renamed in another domain: one answer only.
|
|
1180
|
+
const ownRename = domains.includes(domain) ? matchRenamedMeetingFilename(filename, heads(domain)) : undefined
|
|
1181
|
+
if (ownRename) found = { domain, filename: ownRename }
|
|
1182
|
+
else {
|
|
1183
|
+
const sameName = domains.filter(d => d !== domain && isFile(at(d, filename)))
|
|
1184
|
+
if (sameName.length === 1) found = { domain: sameName[0], filename }
|
|
1185
|
+
else if (sameName.length === 0) {
|
|
1186
|
+
const renamed = domains.filter(d => d !== domain)
|
|
1187
|
+
.map(d => ({ domain: d, filename: matchRenamedMeetingFilename(filename, heads(d)) }))
|
|
1188
|
+
.filter((hit): hit is { domain: string; filename: string } => Boolean(hit.filename))
|
|
1189
|
+
if (renamed.length === 1) found = renamed[0]
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (!found) return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
|
|
1194
|
+
const monthDir = join(operationsDir, found.domain, 'meetings', month)
|
|
1195
|
+
// The same keys the row and the detail carry: the markers too, so the file is read (as the detail reads it).
|
|
1196
|
+
let content: string | null = null
|
|
1197
|
+
try { content = readFileSync(join(monthDir, found.filename), 'utf-8') } catch { content = null }
|
|
1198
|
+
const keys = meetingKeysFor(monthDir, found.filename, content, recordingsRoot, month)
|
|
1199
|
+
const fields = contextFieldsFor(found.filename, keys, meetingPrimaryKeyClaims(operationsDir, recordingsRoot))
|
|
1200
|
+
const resolved = `ops:${found.domain}:${month}:${found.filename}`
|
|
1201
|
+
return resolved === recordId ? fields : { ...fields, resolvedRecordId: resolved }
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1029
1204
|
export function getDirectLibraryMeetingDetail(month: string, filename: string): MeetingDetail | null {
|
|
1030
1205
|
const inspection = resolveMeetingLibrary()
|
|
1031
1206
|
if (inspection.layout !== 'direct' || !inspection.root) return null
|
package/server/lib/jev.ts
CHANGED
|
@@ -88,8 +88,10 @@ export class JevClient {
|
|
|
88
88
|
private failures = 0
|
|
89
89
|
private breakerUntil = 0
|
|
90
90
|
private lastError: string | null = null
|
|
91
|
+
/** `cap` is the daily input-token cap for THIS client's ledger. Work search (6.65.0) passes its own; the default is
|
|
92
|
+
* COS_JEV_DAILY_TOKENS, shared by Work intake advice, session recommendations and completion checks. */
|
|
91
93
|
constructor(private readonly fetchImpl: Fetch = fetch, private readonly now: () => Date = () => new Date(),
|
|
92
|
-
private readonly usageFile = JEV_USAGE_FILE) {}
|
|
94
|
+
private readonly usageFile = JEV_USAGE_FILE, private readonly cap: () => number = dailyCap) {}
|
|
93
95
|
|
|
94
96
|
usedToday(): number {
|
|
95
97
|
try {
|
|
@@ -121,7 +123,7 @@ export class JevClient {
|
|
|
121
123
|
status(): { configured: boolean; source: JevKeySource; savedAt?: string; validatedAt?: string; usedToday: number; dailyCap: number; breakerOpenUntil: string | null; lastError: string | null } {
|
|
122
124
|
const key = resolveJevKey()
|
|
123
125
|
return { configured: !!key, source: key?.source ?? 'none', savedAt: key?.savedAt, validatedAt: key?.validatedAt,
|
|
124
|
-
usedToday: this.usedToday(), dailyCap:
|
|
126
|
+
usedToday: this.usedToday(), dailyCap: this.cap(),
|
|
125
127
|
breakerOpenUntil: this.breakerUntil > this.now().getTime() ? new Date(this.breakerUntil).toISOString() : null, lastError: this.lastError }
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -130,7 +132,7 @@ export class JevClient {
|
|
|
130
132
|
const key = resolveJevKey()
|
|
131
133
|
if (!key) throw new JevError('jev_not_configured')
|
|
132
134
|
if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
|
|
133
|
-
if (this.usedToday() + estimate >
|
|
135
|
+
if (this.usedToday() + estimate > this.cap()) this.fail('jev_cap_reached', false)
|
|
134
136
|
let res: Response
|
|
135
137
|
try {
|
|
136
138
|
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting context keys (6.64.0): the ids COS Control files a meeting's dropped files under.
|
|
3
|
+
*
|
|
4
|
+
* Miles, 2026-10-06: "link" meetings with files dragged onto them, the way Work cards take files, so a screen or slide
|
|
5
|
+
* shared in a meeting becomes agent context. COS Control keeps the files on the Mac (`~/cos-data/meeting-context`); the
|
|
6
|
+
* server only says which key a meeting answers to.
|
|
7
|
+
*
|
|
8
|
+
* READ-ONLY, FROM IDS THE MEETING ALREADY CARRIES. Plan v1 wrote a marker into the meeting file and failed review: more
|
|
9
|
+
* than a dozen writers regenerate, archive or race those files, and a write changes the hashes merge suggestions, merge
|
|
10
|
+
* decisions, Work review and speaker review depend on. So the key is read from the meeting's own source ids:
|
|
11
|
+
* - `g2:<sessionId>` from its `.g2-chunks.json` sidecar (the speaker system's key; moves with every G2 rename);
|
|
12
|
+
* - `ff:<id>` from its `.fireflies.json` sidecar;
|
|
13
|
+
* - `g2:<id>` for every `<!-- g2-session -->` the file declares, which is how a merged meeting also shows the files of
|
|
14
|
+
* the capture it absorbed.
|
|
15
|
+
* Measured 2026-10-06: 649 of 656 meetings from August to October carry at least one.
|
|
16
|
+
*
|
|
17
|
+
* IDENTITY IS AN EXACT ID. Never a title or a date (the rule `resolveMergedScribe` already keeps). Two live meetings whose
|
|
18
|
+
* own sidecars give the same PRIMARY key are `ambiguous`: Control adds and removes nothing there and never files a card
|
|
19
|
+
* link under their keys (files already dropped stay with the cards linked to the meeting they were dropped on).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** A key as Control stores it. The id part is the server's own marker grammar (`G2_SESSION_MARKER`). */
|
|
26
|
+
export const MEETING_CONTEXT_KEY = /^(?:g2|ff):[A-Za-z0-9:_-]{3,96}$/
|
|
27
|
+
const SOURCE_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
28
|
+
|
|
29
|
+
const SIDECAR_HEAD_BYTES = 4096
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The value of a TOP-LEVEL string field in the head of a JSON object, or undefined.
|
|
33
|
+
*
|
|
34
|
+
* A Fireflies sidecar is up to megabytes of sentences, so only its head is read, and a regex alone would also match an
|
|
35
|
+
* `"id"` nested inside `participants` or `sentences` if the key order ever changed. This walks the head tracking depth
|
|
36
|
+
* and strings, and answers only for a key at depth 1.
|
|
37
|
+
*/
|
|
38
|
+
export function topLevelStringField(head: string, field: string): string | undefined {
|
|
39
|
+
let depth = 0
|
|
40
|
+
let i = 0
|
|
41
|
+
while (i < head.length) {
|
|
42
|
+
const c = head[i]
|
|
43
|
+
if (c === '"') {
|
|
44
|
+
let j = i + 1
|
|
45
|
+
let raw = ''
|
|
46
|
+
while (j < head.length && head[j] !== '"') {
|
|
47
|
+
if (head[j] === '\\') { raw += head.slice(j, j + 2); j += 2; continue }
|
|
48
|
+
raw += head[j]; j += 1
|
|
49
|
+
}
|
|
50
|
+
if (j >= head.length) return undefined
|
|
51
|
+
// A key at depth 1 is followed by a colon; read its value if it is the one asked for.
|
|
52
|
+
let k = j + 1
|
|
53
|
+
while (k < head.length && /\s/.test(head[k])) k += 1
|
|
54
|
+
if (depth === 1 && head[k] === ':' && raw === field) {
|
|
55
|
+
k += 1
|
|
56
|
+
while (k < head.length && /\s/.test(head[k])) k += 1
|
|
57
|
+
if (head[k] !== '"') return undefined
|
|
58
|
+
const end = head.indexOf('"', k + 1)
|
|
59
|
+
if (end < 0) return undefined
|
|
60
|
+
const value = head.slice(k + 1, end)
|
|
61
|
+
return value.includes('\\') ? undefined : value
|
|
62
|
+
}
|
|
63
|
+
i = j + 1
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (c === '{' || c === '[') depth += 1
|
|
67
|
+
else if (c === '}' || c === ']') depth -= 1
|
|
68
|
+
i += 1
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The Fireflies transcript id in `<stem>.fireflies.json` beside a meeting, if there is one. Reads 4 KB, never a link. */
|
|
74
|
+
export function firefliesSidecarId(monthDir: string, meetingFilename: string): string | undefined {
|
|
75
|
+
if (!meetingFilename.endsWith('.md')) return undefined
|
|
76
|
+
const path = join(monthDir, meetingFilename.replace(/\.md$/, '.fireflies.json'))
|
|
77
|
+
let fd: number | null = null
|
|
78
|
+
try {
|
|
79
|
+
const stat = lstatSync(path)
|
|
80
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) return undefined
|
|
81
|
+
if (dirname(realpathSync(path)) !== realpathSync(monthDir)) return undefined
|
|
82
|
+
// Non-blocking and no-follow (hazard-invariants): a FIFO planted under the sidecar's name must never hang the
|
|
83
|
+
// server's one thread, and a link swapped in after the lstat is refused at the open itself.
|
|
84
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
|
|
85
|
+
if (!fstatSync(fd).isFile()) return undefined
|
|
86
|
+
const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
|
|
87
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
88
|
+
const id = topLevelStringField(buffer.subarray(0, read).toString('utf8'), 'id')
|
|
89
|
+
return id && SOURCE_ID.test(id) ? id : undefined
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
} finally {
|
|
93
|
+
if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The ordered, de-duplicated keys for one meeting. The first is primary: the capture's own sidecar, then Fireflies,
|
|
98
|
+
* then what the file declares. */
|
|
99
|
+
export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[] }): string[] {
|
|
100
|
+
const keys: string[] = []
|
|
101
|
+
const add = (prefix: 'g2' | 'ff', id: string | undefined) => {
|
|
102
|
+
if (!id || !SOURCE_ID.test(id)) return
|
|
103
|
+
const key = `${prefix}:${id}`
|
|
104
|
+
if (!keys.includes(key)) keys.push(key)
|
|
105
|
+
}
|
|
106
|
+
add('g2', sources.g2SessionId)
|
|
107
|
+
add('ff', sources.firefliesId)
|
|
108
|
+
for (const id of sources.declared ?? []) add('g2', id)
|
|
109
|
+
return keys
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface MeetingContextFields {
|
|
113
|
+
contextKeys: string[]
|
|
114
|
+
contextSupported: boolean
|
|
115
|
+
contextAmbiguous?: true
|
|
116
|
+
contextReason?: 'no_source_id' | 'read_only_record' | 'conflict_copy' | 'ambiguous'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a row or a detail says about its files.
|
|
121
|
+
*
|
|
122
|
+
* `claimants` is how many live meetings have this meeting's primary key as THEIR primary key (this one included). Two
|
|
123
|
+
* or more is ambiguous: the keys still go out, so Control can show files already there, but nothing is added or sent.
|
|
124
|
+
*/
|
|
125
|
+
export function meetingContextFields(keys: string[], options: { conflictCopy?: boolean; claimants?: number } = {}): MeetingContextFields {
|
|
126
|
+
if (options.conflictCopy) return { contextKeys: [], contextSupported: false, contextReason: 'conflict_copy' }
|
|
127
|
+
if (keys.length === 0) return { contextKeys: [], contextSupported: false, contextReason: 'no_source_id' }
|
|
128
|
+
if ((options.claimants ?? 1) > 1) return { contextKeys: keys, contextSupported: false, contextAmbiguous: true, contextReason: 'ambiguous' }
|
|
129
|
+
return { contextKeys: keys, contextSupported: true }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fields for a record this release does not key: a direct library, an import or a re-derived (blended) record. */
|
|
133
|
+
export function readOnlyContext(): MeetingContextFields {
|
|
134
|
+
return { contextKeys: [], contextSupported: false, contextReason: 'read_only_record' }
|
|
135
|
+
}
|
|
@@ -136,6 +136,16 @@ export interface MeetingMeta {
|
|
|
136
136
|
sources?: Array<{ kind: 'g2' | 'fireflies'; id: string; recordId?: string }>
|
|
137
137
|
/** Present only when the server can state a truthful local record. */
|
|
138
138
|
canonicalRecord?: string
|
|
139
|
+
/** 6.64.0, additive and read-only. The keys COS Control files a meeting's dropped files under, from source ids the
|
|
140
|
+
* meeting already carries (`g2:<sessionId>`, `ff:<firefliesId>`, then every `<!-- g2-session -->` it declares).
|
|
141
|
+
* First = primary. Never computed from a title or a date, and nothing is written into the meeting. See
|
|
142
|
+
* `meeting-context-keys.ts`. */
|
|
143
|
+
contextKeys?: string[]
|
|
144
|
+
/** False when files cannot be added here: a re-derived or read-only record, a conflict copy, a meeting with no
|
|
145
|
+
* source id, or two meetings claiming one key. `contextReason` says which. */
|
|
146
|
+
contextSupported?: boolean
|
|
147
|
+
contextAmbiguous?: boolean
|
|
148
|
+
contextReason?: 'no_source_id' | 'read_only_record' | 'conflict_copy' | 'ambiguous'
|
|
139
149
|
/** Additive. Unique sidecar speakers + whether a human correction landed. */
|
|
140
150
|
voiceReview?: {
|
|
141
151
|
voices: number
|
|
@@ -579,6 +589,18 @@ export class MeetingStore {
|
|
|
579
589
|
return detail
|
|
580
590
|
}
|
|
581
591
|
|
|
592
|
+
/** 6.64.0: the session in a stored recording's sidecar, for its context key only. The detail itself keeps its shape
|
|
593
|
+
* (no sessionId, its recordId unchanged). Undefined when the month, the file or the sidecar is not readable. */
|
|
594
|
+
storedSessionId(month: string, filename: string): string | undefined {
|
|
595
|
+
if (!MONTH_PATTERN.test(month) || !SAFE_FILENAME_PATTERN.test(filename) || basename(filename) !== filename) return undefined
|
|
596
|
+
const rootReal = this.rootRealpath()
|
|
597
|
+
if (!rootReal) return undefined
|
|
598
|
+
const monthDir = join(this.root, month)
|
|
599
|
+
const monthReal = safeDirectoryRealpath(monthDir, rootReal)
|
|
600
|
+
if (!monthReal) return undefined
|
|
601
|
+
return this.sidecarSessionId(monthDir, monthReal, filename)
|
|
602
|
+
}
|
|
603
|
+
|
|
582
604
|
private rootRealpath(): string | null {
|
|
583
605
|
return existingRootRealpath(
|
|
584
606
|
this.root,
|