@vibecompass/vibecompass 0.14.0 → 0.16.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 CHANGED
@@ -1,5 +1,188 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-09-30
4
+
5
+ The session brief ships as an opt-in experiment (D-368). In a baseline-versus-brief
6
+ evaluation that did not fully follow its own protocol, the brief arm met the numerical
7
+ recall targets and made none of the labeled forbidden authority claims, but lost nine
8
+ facts that agents without a brief found. That does not support turning it on by
9
+ default, so `start-session` and `continue-session` write a lane brief only when
10
+ `project.yaml` sets `brief.enabled: true`. `vibecompass brief` works on request
11
+ either way.
12
+
13
+ - Read model: architecture components now carry `retrieval_guidance` (the
14
+ `## Retrieval guidance` section) and `retrieval_scope` (the "Retrieval
15
+ scope" bullet under `## Review metadata`); both are `null` when absent.
16
+ Existing fields are unchanged.
17
+ - Add typed decision references and declared decision lineage
18
+ (`decision_lineage` on the read model, contract version 1). Architecture
19
+ docs `cites` decisions, session notes `references` them and `made` the ones
20
+ listed at the start of a top-level "Decisions made" bullet, and decision
21
+ entries declare `supersedes` / `amends` / `preserves` only from their own
22
+ wording, with extent (`full` / `partial` / `scoped` / `unqualified`) and
23
+ scope. Other lineage-like wording is `unknown`; mentions are `references`;
24
+ nothing is inferred as "governs" or "currently valid" (D-359). Every
25
+ relation carries source path, section, line, document and section hashes,
26
+ and explicit-or-inferred evidence (range interiors are inferred).
27
+ - Lineage stays conservative: fenced examples, another decision's restated,
28
+ reported, or uncertain claim ("D-020 supersedes D-010", "As D-020
29
+ supersedes …", "Documents how D-020 supersedes …"), contrast objects
30
+ ("separate from D-169"), hypothetical wording (may, would, if, unless, …),
31
+ and negated preservation ("cannot preserve") never certify a relation;
32
+ prohibited change ("cannot supersede", "cannot be superseded by <this
33
+ entry>") is preservation, and a requirement ("must retain") still
34
+ certifies preservation. An
35
+ `unknown` is kept beside certified relations unless one shares its object
36
+ (coordinated verbs). Historical retention ("kept for historical reference")
37
+ never overrides partial or scoped wording. Decision IDs must be positive
38
+ safe integers, so malformed ranges cannot loop.
39
+ - Recognize the optional structured lineage fields `**Supersedes:**`,
40
+ `**Partially supersedes:**`, `**Amends:**`, and `**Preserves:**` on new
41
+ decision entries (D-363); they win over prose for the targets they name, and
42
+ malformed items become diagnostics.
43
+ - Add `getDecisionLineage(readModel, id)` (declared successors transitively,
44
+ incoming and outgoing lineage, citations, and the notes that made it) and
45
+ export the pure extractors (`extractDecisionFileRelations`,
46
+ `extractSessionNoteRelations`, `extractArchitectureDocCitations`,
47
+ `scanDecisionReferences`, `buildDecisionLineageModel`) with shared fixtures
48
+ in `src/tests/fixtures/decision-lineage/` for the app port.
49
+ - Add `vibecompass brief` and the read-only session brief engine
50
+ (`buildSessionBrief`, `renderBrief`; plan task A3, D-359). Given a task, and
51
+ optionally files, feature slugs, claims, a lane, and a budget, it selects the
52
+ memory a session should read first — through the file-owner index (repo
53
+ aliases and workspace paths such as `vibecompass-app/src/…` both match),
54
+ lane-declared docs, feature slugs, and deterministic keyword matching on
55
+ titles, Description, and Retrieval guidance (no embeddings, no network, no
56
+ model) — and packs whole units (lane, decision-lineage, doc, note,
57
+ watch-out) into an estimated token budget (Unicode code points ÷ 4 over the
58
+ whole rendered brief; default 6,000, minimum 800). Every brief states
59
+ `complete`, `partial`, `incomplete` (with prioritized required reads, also
60
+ used for retrieval errors), or `no-match`, lists at most 12 follow-up reads
61
+ plus "+N more" (the full list is in `--json`), and never emits a decision
62
+ without every declared superseding or amending successor. Uncertified
63
+ lineage wording becomes a follow-up read, never a status. The command
64
+ writes nothing: no manifest refresh, state write, lane write, or agent-file
65
+ sync, so it is safe against a hand-maintained root.
66
+ - Session brief in the lane lifecycle (plan task A4, D-359, D-364, D-368),
67
+ opt-in: when `project.yaml` sets `brief.enabled: true`, `start-session` and
68
+ `continue-session` write `sessions/active/<lane-id>/brief.md` from the lane's working-on,
69
+ claims, and feature slugs, after every lifecycle write succeeded and inside
70
+ the same lock. `continue-session` keeps a current brief and regenerates a
71
+ missing or stale one, and lists it under "Read next". A brief failure is a
72
+ warning plus, when the file can be written, an `incomplete` brief naming the
73
+ reason; it never fails the command, and existing lifecycle refusals fail as
74
+ before. Without the setting (or with `brief.enabled: false`), lifecycle
75
+ commands print `Brief: off (…)`, write no brief, and leave a brief already
76
+ in the lane untouched; `--no-brief` (start and continue) skips generation
77
+ for one command on a root that opted in. Every other lifecycle effect is
78
+ unchanged. `vibecompass brief --write` writes or regenerates the lane brief
79
+ on request whatever the setting; `vibecompass brief --check [--json]`
80
+ reports whether it is stale, without writing.
81
+ - `brief.md` opens with a YAML header that binds its inputs: source root and
82
+ lane with the lane's session date and number, destination root and lane, a
83
+ digest of the canonical corpus it read, short hashes of the docs, decision
84
+ entries, and notes it drew text from, the lane scratch it read (`handoff.md`
85
+ bytes, the `session.yaml` fields it uses, the other active lanes), and the
86
+ repo alias map, plus ready-to-run check and refresh commands that keep the
87
+ root, lane, and source explicit. The body is exactly what
88
+ `vibecompass brief` prints for the lane. Staleness is computed from the
89
+ bindings, not stored: any hash, the session identity, or the alias map
90
+ differs, or the check cannot read what it needs; resume bookkeeping
91
+ (`resumed_at`, `resume_count`) never makes a brief stale. Memory that
92
+ cannot be read (invalid settings included) is a failed generation
93
+ (`generation: "failed"`; `brief --write` exits 1), while mandatory units
94
+ overflowing the budget is a successful `incomplete` brief.
95
+ - `project.yaml` gains an optional `brief:` mapping (D-364): `enabled`
96
+ (default `false`; `true` opts the root into lifecycle generation, D-368),
97
+ and `exclude`, a list of root-relative globs (`*`, `**`, `?`; case-insensitive)
98
+ naming canonical documents every brief must never read. Excluded files are
99
+ dropped from the scan before they are opened, so they contribute nothing to
100
+ selection, ranking, citation propagation, bindings, or output. Malformed or
101
+ unknown `brief` settings, or an unreadable `project.yaml`, fail closed: the
102
+ brief reads no canonical document and is `incomplete`; scans warn with
103
+ `project-brief-invalid`. `loadProjectReadModel` and other consumers are
104
+ unaffected.
105
+ - Dual-root source for dogfood roots (D-255, D-364): `continue-session`,
106
+ `brief --write`, and `brief --check` accept `--source-root <root>` (and
107
+ `--source-session <lane-id>`, which must equal the lane) to build the brief
108
+ from another memory root's matching lane — same id, session date, and
109
+ session number, or the brief is refused (`incomplete`, with the reason).
110
+ The source root is only read (no lock, manifest, lane, stamp, or agent-file
111
+ write) and is byte-identical afterwards; both roots' exclusions apply;
112
+ destination repo ids translate to the source's by matching remotes. The
113
+ option is never inferred: a brief built from another root is stale for a
114
+ run that does not name it, and that root is not read.
115
+ - Generated `context.md`, the managed agent block, and the scratch listings
116
+ now tell agents to read the lane's `brief.md`, when present, before planning,
117
+ and describe lifecycle generation as opt-in. The lane
118
+ `wip.md` template gains an optional `## Context used` section for the docs
119
+ and decisions the lane actually relied on.
120
+
121
+ ## 0.15.0 - 2026-09-25
122
+
123
+ - Add a layered local credential store for hosted sync tokens (D-355). Every
124
+ hosted command now resolves the token in a fixed order: the binding's env
125
+ var when set (per-shell override for CI, automation, and agents), then the
126
+ per-user credential store keyed by hosted URL + project id. The store lives
127
+ outside every project root (`VIBECOMPASS_CONFIG_DIR`, else
128
+ `$XDG_CONFIG_HOME/vibecompass`, else `~/.config/vibecompass`;
129
+ `%APPDATA%\vibecompass` on Windows); its `credentials.json` index is
130
+ written `0600` inside a `0700` directory and a world-readable file is
131
+ refused. Secrets go to the OS keychain when one is available (macOS
132
+ Keychain via `security`, Linux `secret-tool`) and otherwise into the file;
133
+ the index records which backend holds each entry. `project.yaml` is
134
+ unchanged (`credential_source: env` + `credential_env_var` stay required),
135
+ so older CLIs keep working against the env var.
136
+ - `connect-hosted` captures the token after writing the binding: a hidden
137
+ prompt on a TTY, `--token-stdin` for scripts and agents, `--no-store` to
138
+ keep the env-only path, and `--credential-store <auto|keychain|file>`.
139
+ When the env var is already set it says so and points at
140
+ `sync-credential set --from-env` instead of storing silently.
141
+ - Add `vibecompass login [--sync-target <name>] [--api-url <url>]
142
+ [--project-id <id>] [--label <text>] [--no-browser] [--poll-timeout <s>]`:
143
+ a browser device-code sign-in. The CLI prints and opens the dashboard
144
+ approval link with a short code, polls until the project owner approves,
145
+ stores the minted token, and writes the sync binding when the root is not
146
+ bound yet (promoting `local-only` to `local-primary` like `connect-hosted`).
147
+ An unknown `--sync-target` is created only with an explicit `--api-url`;
148
+ a mismatched `--api-url` on a bound root fails closed.
149
+ - Add `vibecompass sync-credential <set|remove|list>`: store a dashboard
150
+ token (hidden prompt, `--token-stdin`, or `--from-env`), forget one, or
151
+ list stored entries (metadata only, never values; `--json`). `--api-url`
152
+ + `--project-id` address a hosted project without a bound root.
153
+ - `status` prints `Hosted sync:` and `Sync credential:` lines (source only:
154
+ env var, OS keychain, credential store file, or missing) and recommends
155
+ `vibecompass login` when nothing holds a token. Missing-credential errors
156
+ from push, pull, mode transitions, and hosted docs-review now name
157
+ `login` first, then `sync-credential set`, then the env var override.
158
+ - Init and connect-hosted no longer tell users to export the token in
159
+ every terminal or persist it in a shell profile.
160
+ - Credential store hardening (review passes 1–3): every store mutation
161
+ takes a cross-process lock file next to the index that appears atomically
162
+ (owner record hard-linked into place) and names its owner (id, pid, host).
163
+ The lock is never taken from a live process on this host, however long it
164
+ waits on a keychain prompt; writers wait up to 10s, then fail naming the
165
+ owner. A lock whose owner process no longer exists is recovered through a
166
+ second reclaim lock so exactly one reclaimer removes it after re-reading
167
+ the owner; locks from another host, unreadable locks, and a reclaim lock
168
+ left by a crashed reclaimer are never removed automatically (the error
169
+ names them). The index is written through an exclusively created random
170
+ temp file plus atomic rename while the lock is held, so parallel logins,
171
+ `sync-credential set`, and `remove` never drop each other's entries;
172
+ target identity lowercases only scheme and host (paths keep their case);
173
+ keychain labels are sanitized and keys outside the keychain-safe charset
174
+ map to a digest account, so any project name can be stored; under the
175
+ default `auto` policy a failed keychain write (headless session, locked
176
+ keychain, missing Secret Service) falls back to the file store with a
177
+ printed warning instead of losing a one-time claim. `login` bounds every
178
+ network wait (start, polls, body reads) by `--poll-timeout`.
179
+ - `login` is the one recommended connection path (D-356): help lists it
180
+ right after `init`, `init` and missing-token errors point only at it, and
181
+ `connect-hosted` on a terminal starts the browser sign-in when you press
182
+ Enter at its token prompt. Dashboard tokens, `--token-stdin`,
183
+ `sync-credential`, and the env-var override remain for CI, scripts, and
184
+ browserless machines and are labelled advanced.
185
+
3
186
  ## 0.14.0 - 2026-09-23
4
187
 
5
188
  - Add `continue-session [<lane-id>]` (D-353): resume an already-open lane
package/README.md CHANGED
@@ -143,6 +143,17 @@ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session a
143
143
  # start-session --id <existing-lane> fails closed and points at continue-session.
144
144
  npx -y @vibecompass/vibecompass@latest continue-session auth-flow --root .compass --working-on "Auth flow, phase 2"
145
145
 
146
+ # Session brief in the lane (D-359, D-364), opt-in (D-368): with
147
+ # project.yaml `brief: { enabled: true }`, start-session and continue-session
148
+ # write sessions/active/<lane-id>/brief.md — the memory the lane's task should
149
+ # start from, with a YAML header binding the inputs it read. continue-session
150
+ # keeps a current brief and regenerates a stale one; a brief failure is a
151
+ # warning, never a failed start or resume. Without the setting nothing is
152
+ # written, and --no-brief skips it for one command. brief --write briefs any
153
+ # lane on request; `brief.exclude` lists root-relative globs no brief may read.
154
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --write # write or regenerate it
155
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --check # current or stale, and why
156
+
146
157
  # Optional git binding for a lane (D-281): create/reuse a branch in each bound
147
158
  # repo; --worktree provisions per-repo worktrees under
148
159
  # <workspace>/worktrees/<lane-id>/<repo-id> with the lane marker in the
@@ -186,6 +197,12 @@ npx -y @vibecompass/vibecompass@latest sync-agents --root .compass
186
197
  npx -y @vibecompass/vibecompass@latest append-decision --root .compass --target cross-cutting.md --entry staged-entry.md
187
198
  npx -y @vibecompass/vibecompass@latest refresh-decision-index --root .compass --check
188
199
 
200
+ # Session brief (read-only): the memory a task should start from, packed into
201
+ # an estimated token budget, with a complete/partial/incomplete/no-match status
202
+ # and follow-up reads. Decisions always appear with their declared successors.
203
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --task "Change the refresh denial message" --files app:src/lib/entitlements.ts
204
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --budget 3000 --json
205
+
189
206
  # Targeted and comprehensive architecture docs maintenance
190
207
  npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow --changed app:src/auth/login.ts
191
208
  npx -y @vibecompass/vibecompass@latest docs-review --root .compass --guided
@@ -240,10 +257,57 @@ workflow files, updating handoffs, and applying reviewed changes. Use
240
257
  `docs update` / `vibecompass docs-update` for ordinary session-delta
241
258
  maintenance tied to the current lane.
242
259
 
260
+ ## Session Brief (opt-in)
261
+
262
+ `vibecompass brief` picks the architecture docs, decisions, and session notes a
263
+ task should start from, and packs them into a short markdown brief with a
264
+ `complete`, `partial`, `incomplete`, or `no-match` status and a list of
265
+ follow-up reads. It runs locally and deterministically: no network, no model,
266
+ no account. A decision always appears together with any later decision whose
267
+ own text explicitly declares that it replaces or amends it, and the brief never
268
+ claims that a decision governs your task. It cannot spot a replacement that is
269
+ only implied, so read the follow-up list too.
270
+
271
+ The brief in a session lane is an experiment you turn on (D-368):
272
+
273
+ ```yaml
274
+ # .compass/project.yaml
275
+ brief:
276
+ enabled: true
277
+ ```
278
+
279
+ With it on, `start-session` and `continue-session` write
280
+ `sessions/active/<lane-id>/brief.md`, and your AI tool reads it before
281
+ planning. Without it, nothing changes, and
282
+ `vibecompass brief --session <lane-id> --write` still briefs one lane on
283
+ request. `brief.exclude` lists files no brief may read.
284
+
285
+ The brief is opt-in because of the project's own evaluation, which did not
286
+ fully follow its protocol. Agents with a brief found about as much of the
287
+ relevant memory as agents without one, and made none of the specific false
288
+ authority claims the evaluation checked for. But they missed nine facts the
289
+ others found, in one case after the brief left out the key document. Treat a
290
+ brief as a starting point, not the whole answer.
291
+
243
292
  ## Hosted Sync
244
293
 
245
294
  Use hosted sync when you want browser review, team workflows, or hosted
246
- docs-review proposals:
295
+ docs-review proposals. The quickest path signs you in from the terminal and
296
+ keeps the token in your OS keychain (or a private per-user file), so it works
297
+ in every new terminal and in agents launched from a desktop app:
298
+
299
+ ```bash
300
+ npx -y @vibecompass/vibecompass@latest login --root .compass
301
+ # opens vibecompass.dev, you confirm a short code and pick the project,
302
+ # the token is stored locally, and the hosted binding is written to project.yaml
303
+
304
+ npx -y @vibecompass/vibecompass@latest push --root .compass
305
+ ```
306
+
307
+ Advanced (CI, scripts, a machine without a browser, or a hosted-only demote):
308
+ bind the root with a dashboard-created token, pasted at the hidden prompt or
309
+ piped in. On a terminal, pressing Enter at that prompt starts the browser
310
+ sign-in instead:
247
311
 
248
312
  ```bash
249
313
  npx -y @vibecompass/vibecompass@latest connect-hosted \
@@ -251,8 +315,12 @@ npx -y @vibecompass/vibecompass@latest connect-hosted \
251
315
  --sync-api-url https://vibecompass.dev \
252
316
  --sync-project-id vc_proj_example \
253
317
  --sync-credential-env-var VIBECOMPASS_SYNC_TOKEN
318
+ # "Paste the sync token from the dashboard (input hidden)"
254
319
 
255
- npx -y @vibecompass/vibecompass@latest push --root .compass
320
+ # non-interactive:
321
+ printf '%s' "$TOKEN" | npx -y @vibecompass/vibecompass@latest connect-hosted --root .compass \
322
+ --sync-api-url https://vibecompass.dev --sync-project-id vc_proj_example \
323
+ --sync-credential-env-var VIBECOMPASS_SYNC_TOKEN --token-stdin
256
324
  ```
257
325
 
258
326
  After `close-session`, run `push` again when a connected local-primary root
@@ -260,16 +328,29 @@ changed canonical project-memory files and the hosted dashboard should catch
260
328
  up. Hosted-only projects do not use a local authoritative push; update or
261
329
  refresh them through the hosted dashboard/proposal flow.
262
330
 
263
- The sync token is read from the environment variable you bind (for example
264
- `VIBECOMPASS_SYNC_TOKEN`). One-off `export`s do not survive new terminals —
265
- persist the token in your shell profile (`~/.zshenv` or `~/.bashrc`):
331
+ ### Where the token lives (D-355)
332
+
333
+ Hosted commands look for the token in this order:
334
+
335
+ 1. the environment variable named in `project.yaml` (`VIBECOMPASS_SYNC_TOKEN`
336
+ by default) — a per-shell override for CI and scripts;
337
+ 2. the local credential store, keyed by hosted URL + project id: the OS
338
+ keychain when available (macOS Keychain, Linux `secret-tool`), otherwise
339
+ `~/.config/vibecompass/credentials.json` (`0600`; `%APPDATA%\vibecompass`
340
+ on Windows; `VIBECOMPASS_CONFIG_DIR` overrides the location).
341
+
342
+ The token value is never written to `project.yaml`, `state/`, or any file in
343
+ your repo. Manage stored tokens with:
266
344
 
267
345
  ```bash
268
- echo 'export VIBECOMPASS_SYNC_TOKEN="<your sync token>"' >> ~/.zshenv
346
+ npx -y @vibecompass/vibecompass@latest sync-credential list --root .compass
347
+ npx -y @vibecompass/vibecompass@latest sync-credential set --root .compass # hidden prompt, --token-stdin, or --from-env
348
+ npx -y @vibecompass/vibecompass@latest sync-credential remove --root .compass
269
349
  ```
270
350
 
271
- If the token is lost, rotate it on the hosted dashboard under Setup -> Hosted
272
- sync and export the new value.
351
+ `vibecompass status` shows which source is in use (never the value). Lost a
352
+ token? Run `login` again, or create a new one on the dashboard under Setup ->
353
+ Hosted sync and revoke the old one there.
273
354
 
274
355
  ### Multiple environments
275
356
 
@@ -304,6 +385,9 @@ import {
304
385
  scanProjectMemory,
305
386
  loadProjectReadModel,
306
387
  getProjectContext,
388
+ getDecisionLineage,
389
+ buildSessionBrief,
390
+ renderBrief,
307
391
  detectChangelogShapedLines,
308
392
  ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES,
309
393
  } from "@vibecompass/vibecompass";
@@ -319,6 +403,25 @@ so external validators (for example the hosted proposal parser) stay in parity.
319
403
  `ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES` is the byte-valued soft size budget
320
404
  (12000) both docs-review apply warnings and the docs-update size advisory read.
321
405
 
406
+ `loadProjectReadModel(root)` also returns `decision_lineage`: evidence-typed
407
+ relations between documents and decisions. Architecture docs `cites`
408
+ decisions; session notes `references` them and `made` the ones listed under
409
+ "Decisions made". Decision entries declare `supersedes`, `amends`, or
410
+ `preserves` only from their own wording, with an extent and scope; anything
411
+ the parser cannot certify is `unknown`. Every relation carries its source
412
+ path, section, line, and content hashes. `getDecisionLineage(readModel, id)`
413
+ returns one decision's incoming and outgoing lineage and its declared
414
+ successors, followed transitively. Nothing is ever reported as "governs" or
415
+ "currently valid". Architecture components also carry `retrieval_guidance`
416
+ and `retrieval_scope` when their docs define them.
417
+
418
+ `buildSessionBrief({ rootDir, task, files, featureSlugs, claims, laneId, budget })`
419
+ is the read-only engine behind `vibecompass brief`: it returns the selection
420
+ result (status, packed units, omitted units, follow-up reads, gaps, and the
421
+ estimated size), and `renderBrief(result)` turns it into markdown. It never
422
+ writes to the root, and it never reads a path the root's `project.yaml`
423
+ `brief.exclude` names.
424
+
322
425
  ## More Documentation
323
426
 
324
427
  - Developer guide: https://vibecompass.dev/developers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Deterministic keyword matching for the session brief (plan task A3; D-359):
3
+ * a tokenizer with light suffix stemming and a BM25F-style scorer over a
4
+ * small fielded index. No embeddings, no network, no model — the same inputs
5
+ * always produce the same terms and scores.
6
+ */
7
+
8
+ const STOPWORDS = new Set(
9
+ (
10
+ 'a about above after again against all also am an and any are as at be because been before being below between both but by ' +
11
+ 'can cannot could did do does doing done down during each either etc even ever every few for from further had has have having ' +
12
+ 'he her here hers him his how however i if in into is it its itself just me more most much my neither no nor not now of off on ' +
13
+ 'once one only onto or other our ours out over own per same she should so some such than that the their theirs them then there ' +
14
+ 'these they this those though through thus to too under until up upon us very via was we were what whatever when where whether ' +
15
+ 'which while who whom whose why will with within without would yet you your yours'
16
+ ).split(' '),
17
+ );
18
+
19
+ // Instruction framing that says what to do, not what the task is about.
20
+ // Dropped from query text only; indexed memory keeps every content word.
21
+ const QUERY_FRAMING = new Set(
22
+ (
23
+ 'add answer anything change create edit everything afterward afterwards happen implement include including instead make ' +
24
+ 'modify need please rewrite something thing things walk want write'
25
+ ).split(' '),
26
+ );
27
+
28
+ const IRREGULAR = new Map([
29
+ ['paid', 'pay'],
30
+ ['made', 'make'],
31
+ ['built', 'build'],
32
+ ['ran', 'run'],
33
+ ['written', 'write'],
34
+ ['wrote', 'write'],
35
+ ['chosen', 'choose'],
36
+ ['chose', 'choose'],
37
+ ]);
38
+
39
+ // Ordered suffix rules: the first rule whose suffix matches (with at least
40
+ // `min` characters left) applies. A final `e` is dropped afterwards so
41
+ // "translate" and "translation" meet at "translat".
42
+ const SUFFIX_RULES = [
43
+ ['ational', 'ate', 3],
44
+ ['izations', 'ize', 3],
45
+ ['ization', 'ize', 3],
46
+ ['ations', 'ate', 3],
47
+ ['ation', 'ate', 3],
48
+ ['ements', '', 4],
49
+ ['ement', '', 4],
50
+ ['ments', '', 3],
51
+ ['ment', '', 3],
52
+ ['nesses', '', 3],
53
+ ['ness', '', 3],
54
+ ['ingly', '', 3],
55
+ ['ings', '', 3],
56
+ ['ing', '', 3],
57
+ ['edly', '', 3],
58
+ ['ies', 'y', 2],
59
+ ['ied', 'y', 2],
60
+ ['sses', 'ss', 2],
61
+ ['xes', 'x', 2],
62
+ ['ches', 'ch', 2],
63
+ ['shes', 'sh', 2],
64
+ ['ed', '', 3],
65
+ ['ly', '', 4],
66
+ ['s', '', 3],
67
+ ];
68
+
69
+ export function stemWord(word) {
70
+ const lower = word.toLowerCase();
71
+ if (IRREGULAR.has(lower)) return IRREGULAR.get(lower);
72
+ if (lower.length <= 3 || lower.endsWith('ss') || lower.endsWith('us') || lower.endsWith('is')) {
73
+ return lower;
74
+ }
75
+
76
+ let stem = lower;
77
+ for (const [suffix, replacement, min] of SUFFIX_RULES) {
78
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= min) {
79
+ stem = stem.slice(0, stem.length - suffix.length) + replacement;
80
+ break;
81
+ }
82
+ }
83
+
84
+ // "running" → "runn" → "run"; keep "ll"/"ss"/"zz" doubles ("install", "access").
85
+ if (stem !== lower && /([b-df-hj-kmnp-rtv-y])\1$/.test(stem) && !/(ll|ss|zz)$/.test(stem)) {
86
+ stem = stem.slice(0, -1);
87
+ }
88
+ if (stem.length > 4 && stem.endsWith('e')) {
89
+ stem = stem.slice(0, -1);
90
+ }
91
+ return stem;
92
+ }
93
+
94
+ /** Raw lowercase word tokens: letters and digits, split on everything else. */
95
+ export function splitWords(text) {
96
+ return String(text ?? '')
97
+ .toLowerCase()
98
+ .split(/[^a-z0-9]+/)
99
+ .filter((word) => word.length >= 2 && !/^\d+$/.test(word));
100
+ }
101
+
102
+ /** Content terms for indexing: stopwords dropped, stemmed, order kept. */
103
+ export function indexTerms(text) {
104
+ const terms = [];
105
+ for (const word of splitWords(text)) {
106
+ if (STOPWORDS.has(word)) continue;
107
+ terms.push(stemWord(word));
108
+ }
109
+ return terms;
110
+ }
111
+
112
+ /** Unique query terms: stopwords and instruction framing dropped, stemmed. */
113
+ export function queryTerms(text) {
114
+ const seen = new Set();
115
+ const terms = [];
116
+ for (const word of splitWords(text)) {
117
+ if (STOPWORDS.has(word) || QUERY_FRAMING.has(word)) continue;
118
+ const stem = stemWord(word);
119
+ if (QUERY_FRAMING.has(stem) || seen.has(stem)) continue;
120
+ seen.add(stem);
121
+ terms.push(stem);
122
+ }
123
+ return terms;
124
+ }
125
+
126
+ /** The first raw word behind each query term, for disclosures ("japanes" → "Japanese"). */
127
+ export function queryTermOrigins(text) {
128
+ const origins = new Map();
129
+ for (const word of String(text ?? '').split(/[^A-Za-z0-9]+/)) {
130
+ if (word.length < 2 || /^\d+$/.test(word)) continue;
131
+ const stem = stemWord(word.toLowerCase());
132
+ if (!origins.has(stem)) origins.set(stem, word);
133
+ }
134
+ return origins;
135
+ }
136
+
137
+ const K1 = 1.2;
138
+
139
+ /**
140
+ * A fielded keyword index. `fields` maps a field name to `{ weight, b }`;
141
+ * each document supplies text per field.
142
+ */
143
+ export function createKeywordIndex(fields, documents) {
144
+ const entries = documents.map((document) => {
145
+ const perField = {};
146
+ for (const name of Object.keys(fields)) {
147
+ const terms = indexTerms(document.fields[name] ?? '');
148
+ const counts = new Map();
149
+ for (const term of terms) counts.set(term, (counts.get(term) ?? 0) + 1);
150
+ perField[name] = { counts, length: terms.length };
151
+ }
152
+ return { id: document.id, perField };
153
+ });
154
+
155
+ const averageLength = {};
156
+ for (const name of Object.keys(fields)) {
157
+ const total = entries.reduce((sum, entry) => sum + entry.perField[name].length, 0);
158
+ averageLength[name] = entries.length > 0 ? Math.max(1, total / entries.length) : 1;
159
+ }
160
+
161
+ const documentFrequency = new Map();
162
+ for (const entry of entries) {
163
+ const seen = new Set();
164
+ for (const name of Object.keys(fields)) {
165
+ for (const term of entry.perField[name].counts.keys()) seen.add(term);
166
+ }
167
+ for (const term of seen) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
168
+ }
169
+
170
+ const count = entries.length;
171
+ const idf = (term) => {
172
+ const frequency = documentFrequency.get(term) ?? 0;
173
+ return Math.log(1 + (count - frequency + 0.5) / (frequency + 0.5));
174
+ };
175
+
176
+ return {
177
+ count,
178
+ documentFrequency: (term) => documentFrequency.get(term) ?? 0,
179
+ idf,
180
+ /** `{ id, score, matched }` per document with any matched term. */
181
+ score(terms) {
182
+ const results = [];
183
+ for (const entry of entries) {
184
+ let score = 0;
185
+ const matched = [];
186
+ for (const term of terms) {
187
+ let pseudo = 0;
188
+ for (const [name, { weight, b }] of Object.entries(fields)) {
189
+ const field = entry.perField[name];
190
+ const frequency = field.counts.get(term);
191
+ if (!frequency) continue;
192
+ pseudo += (weight * frequency) / (1 - b + (b * field.length) / averageLength[name]);
193
+ }
194
+ if (pseudo === 0) continue;
195
+ matched.push(term);
196
+ score += idf(term) * ((pseudo * (K1 + 1)) / (K1 + pseudo));
197
+ }
198
+ if (matched.length > 0) results.push({ id: entry.id, score, matched });
199
+ }
200
+ return results;
201
+ },
202
+ };
203
+ }