projectkeeper 0.0.0-stage → 1.0.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 +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions (Spec §1.16 row 8, §3.11; CKC-22 AC-9): every session's host and start and end; every message's speaker —
|
|
3
|
+
* decided by the session's structure (`isOwnerMessage` of sources/sessions/read.ts: what the owner typed, never a tool
|
|
4
|
+
* result, an injected block or a subagent's task), never guessed — and time; the owner's every message verbatim, and with
|
|
5
|
+
* it, verbatim, the agent message it answered (the nearest earlier agent message with text in the same session: the owner's
|
|
6
|
+
* "可以" has to be read against what it said yes to). Credential values are redacted.
|
|
7
|
+
*
|
|
8
|
+
* What cannot be read is listed as it is: a log that fails to parse keeps its reason; a log read before and gone now stays
|
|
9
|
+
* with everything read from it, marked missing — the ledger is then the only copy of the owner's words in it (D71).
|
|
10
|
+
*
|
|
11
|
+
* Incremental: a log is read again only when its size or modification time changed.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, statSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { getState, hex12, putText, redact, setState, tx, deleteText } from './schema.ts';
|
|
17
|
+
import { locateSessionsForHomes, type LocatedSession } from '../sources/sessions/locate.ts';
|
|
18
|
+
import { isOwnerMessage, parseSession, type ParsedSession, type SessionMessage } from '../sources/sessions/read.ts';
|
|
19
|
+
|
|
20
|
+
export interface SessionScanInput {
|
|
21
|
+
/** Working directories whose sessions belong to the project. */
|
|
22
|
+
readonly cwds: readonly string[];
|
|
23
|
+
/** Which native home to read for a host and directory (a copy's frozen store); the machine's home when not listed. */
|
|
24
|
+
readonly homes?: readonly { readonly host: 'claude' | 'codex'; readonly cwd: string; readonly home: string }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function homeFor(input: SessionScanInput): (host: 'claude' | 'codex', cwd: string) => string {
|
|
28
|
+
return (host, cwd) => input.homes?.find((h) => h.host === host && h.cwd.toLowerCase() === cwd.toLowerCase())?.home ?? homedir();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionScanStats {
|
|
32
|
+
readonly located: number;
|
|
33
|
+
readonly read: number;
|
|
34
|
+
readonly sessions: number;
|
|
35
|
+
readonly messages: number;
|
|
36
|
+
readonly ownerMessages: number;
|
|
37
|
+
readonly unreadable: number;
|
|
38
|
+
readonly missing: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const sessionKey = (host: string, sessionId: string, file: string): string => `session:${hex12(host, sessionId, file.toLowerCase())}`;
|
|
42
|
+
export const messageKey = (session: string, idx: number): string => `msg:${hex12(session, idx)}`;
|
|
43
|
+
|
|
44
|
+
export type Speaker = 'owner' | 'agent' | 'subagent' | 'host';
|
|
45
|
+
export function speakerOf(m: SessionMessage): Speaker {
|
|
46
|
+
if (isOwnerMessage(m)) return 'owner';
|
|
47
|
+
if (m.sidechain) return 'subagent';
|
|
48
|
+
if (m.role === 'user') return 'host';
|
|
49
|
+
return 'agent';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** For each owner message, the position of the agent message it answers: the nearest earlier agent message with text. */
|
|
53
|
+
export function answeredBy(messages: readonly SessionMessage[]): Map<number, number> {
|
|
54
|
+
const out = new Map<number, number>();
|
|
55
|
+
let last: number | null = null;
|
|
56
|
+
for (const m of messages) {
|
|
57
|
+
if (isOwnerMessage(m)) { if (last !== null) out.set(m.index, last); continue; }
|
|
58
|
+
if (m.role === 'assistant' && !m.sidechain && m.text.trim()) last = m.index;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ingest(db: DatabaseSync, located: LocatedSession, parsed: ParsedSession, now: string): { messages: number; owner: number } {
|
|
64
|
+
const key = sessionKey(located.host, parsed.sessionId, located.file);
|
|
65
|
+
const owner = parsed.messages.filter(isOwnerMessage).length;
|
|
66
|
+
db.prepare(`INSERT INTO sessions (key, host, session_id, file, cwd, started_at, ended_at, messages, owner_messages, headless, subagent, bytes, mtime_ms, broken_lines, missing, unreadable, first_seen)
|
|
67
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)
|
|
68
|
+
ON CONFLICT(key) DO UPDATE SET cwd = excluded.cwd, started_at = excluded.started_at, ended_at = excluded.ended_at, messages = excluded.messages,
|
|
69
|
+
owner_messages = excluded.owner_messages, headless = excluded.headless, subagent = excluded.subagent, bytes = excluded.bytes,
|
|
70
|
+
mtime_ms = excluded.mtime_ms, broken_lines = excluded.broken_lines, missing = 0, unreadable = NULL`)
|
|
71
|
+
.run(key, located.host, parsed.sessionId, located.file, parsed.cwd, parsed.startedAt, parsed.endedAt, parsed.messages.length, owner,
|
|
72
|
+
parsed.headless ? 1 : 0, located.isSubagent ? 1 : 0, located.bytes, Math.round(located.mtimeMs), parsed.brokenLines, now);
|
|
73
|
+
// The log only grows; a message already recorded keeps its row (and its id), a changed one is rewritten.
|
|
74
|
+
const answers = answeredBy(parsed.messages);
|
|
75
|
+
const kept = new Set<number>([...answers.values()]);
|
|
76
|
+
const old = new Map((db.prepare('SELECT idx, key, text, at FROM session_messages WHERE session = ?').all(key) as { idx: number; key: string; text: string | null; at: string | null }[]).map((r) => [r.idx, r]));
|
|
77
|
+
const ins = db.prepare(`INSERT OR REPLACE INTO session_messages (key, session, idx, role, speaker, at, text, chars, tools, answers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
78
|
+
const present = new Set<number>();
|
|
79
|
+
for (const m of parsed.messages) {
|
|
80
|
+
present.add(m.index);
|
|
81
|
+
const speaker = speakerOf(m);
|
|
82
|
+
const keep = speaker === 'owner' || kept.has(m.index);
|
|
83
|
+
const text = keep ? redact(m.text) : null;
|
|
84
|
+
const mk = messageKey(key, m.index);
|
|
85
|
+
const before = old.get(m.index);
|
|
86
|
+
if (before && before.text === text && before.at === m.at) continue;
|
|
87
|
+
ins.run(mk, key, m.index, m.role, speaker, m.at, text, [...m.text].length, m.tools.length ? JSON.stringify(m.tools.slice(0, 40).map((t) => redact(t).slice(0, 200))) : null,
|
|
88
|
+
speaker === 'owner' ? answers.get(m.index) ?? null : null);
|
|
89
|
+
if (speaker === 'owner') putText(db, mk, 'owner', null, m.text);
|
|
90
|
+
else if (keep) putText(db, mk, 'agent', null, m.text);
|
|
91
|
+
else if (before?.text) deleteText(db, mk);
|
|
92
|
+
}
|
|
93
|
+
// Messages the log no longer has (a rewritten log) are left as they were read.
|
|
94
|
+
return { messages: parsed.messages.length, owner };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function scanSessions(db: DatabaseSync, input: SessionScanInput, now: string): SessionScanStats {
|
|
98
|
+
const located = input.cwds.length ? locateSessionsForHomes(input.cwds, homeFor(input)) : [];
|
|
99
|
+
let read = 0;
|
|
100
|
+
let unreadable = 0;
|
|
101
|
+
for (const l of located) {
|
|
102
|
+
let size = l.bytes;
|
|
103
|
+
let mtime = l.mtimeMs;
|
|
104
|
+
try { const st = statSync(l.file); size = st.size; mtime = st.mtimeMs; } catch { continue; }
|
|
105
|
+
const stateKey = `session:${l.file.toLowerCase()}`;
|
|
106
|
+
if (getState(db, stateKey) === `${size}:${Math.round(mtime)}`) continue;
|
|
107
|
+
let parsed: ParsedSession;
|
|
108
|
+
try {
|
|
109
|
+
parsed = parseSession(l);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
unreadable += 1;
|
|
112
|
+
const key = sessionKey(l.host, l.sessionId, l.file);
|
|
113
|
+
db.prepare(`INSERT INTO sessions (key, host, session_id, file, cwd, bytes, mtime_ms, unreadable, first_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
114
|
+
ON CONFLICT(key) DO UPDATE SET unreadable = excluded.unreadable, bytes = excluded.bytes, mtime_ms = excluded.mtime_ms`)
|
|
115
|
+
.run(key, l.host, l.sessionId, l.file, l.cwd, size, Math.round(mtime), (error as Error).message.slice(0, 300), now);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
tx(db, () => {
|
|
119
|
+
ingest(db, l, parsed, now);
|
|
120
|
+
setState(db, stateKey, `${size}:${Math.round(mtime)}`);
|
|
121
|
+
});
|
|
122
|
+
read += 1;
|
|
123
|
+
}
|
|
124
|
+
// A log read before and gone now: kept with everything read from it, marked missing (§3.11).
|
|
125
|
+
const seen = new Set(located.map((l) => l.file.toLowerCase()));
|
|
126
|
+
tx(db, () => {
|
|
127
|
+
for (const s of db.prepare('SELECT key, file, missing FROM sessions').all() as { key: string; file: string; missing: number }[]) {
|
|
128
|
+
const gone = !seen.has(s.file.toLowerCase()) && !existsSync(s.file);
|
|
129
|
+
if (gone !== (s.missing === 1)) db.prepare('UPDATE sessions SET missing = ? WHERE key = ?').run(gone ? 1 : 0, s.key);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const t = db.prepare('SELECT count(*) c, ifnull(sum(messages), 0) m, ifnull(sum(owner_messages), 0) o, ifnull(sum(missing), 0) x, ifnull(sum(unreadable IS NOT NULL), 0) u FROM sessions').get() as { c: number; m: number; o: number; x: number; u: number };
|
|
133
|
+
return { located: located.length, read, sessions: t.c, messages: t.m, ownerMessages: t.o, unreadable: t.u, missing: t.x };
|
|
134
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line scans (Spec §1.16 rows 3–5; CKC-22 AC-4, AC-5, AC-6): explicit supersessions, numbering, verdicts. Each
|
|
3
|
+
* distinct content of each document is read once; a line found there becomes one entry per document, followed through the
|
|
4
|
+
* versions: the first version that has it gives its occurred time (§2.11; for import-style root content, AC-15), the last
|
|
5
|
+
* version that has it and whether the current version still does are kept beside it. A document moved to a new path keeps
|
|
6
|
+
* its lines' first appearance from the old path.
|
|
7
|
+
*
|
|
8
|
+
* Numbering runs in two passes: the definitions (numbering.ts) decide the rules; the mentions of the recognised rules are
|
|
9
|
+
* then found everywhere — documents, commit messages, branch and worktree names, file names. When the rules change, the
|
|
10
|
+
* mentions are found again from the start.
|
|
11
|
+
*/
|
|
12
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { isDocumentPath } from '../sources/history.ts';
|
|
14
|
+
import { blobTextKey, getState, getText, hex12, lineIdentity, redact, setState, tx } from './schema.ts';
|
|
15
|
+
import { datesOf, occurredOfLine, versionTime } from './docs.ts';
|
|
16
|
+
import { supersessionInPath, supersessionLines, isReportLike, verdictLines } from './lines.ts';
|
|
17
|
+
import {
|
|
18
|
+
MIN_DEFINED, NUMBERING_VERSION, definitionsInBranch, definitionsInPath, definitionsInSubject, definitionsInText, familyOf, isCommonPair, keepTwoLetterGroups, mentionMatcher,
|
|
19
|
+
nameMatcher, type Definition, type Rule,
|
|
20
|
+
} from './numbering.ts';
|
|
21
|
+
import { materialTime, msOf, type Occurred } from './time.ts';
|
|
22
|
+
import { arrangementKind } from './arrangements.ts';
|
|
23
|
+
import type { RepoHandle } from './repo-scan.ts';
|
|
24
|
+
|
|
25
|
+
export interface TextScanStats {
|
|
26
|
+
readonly contentsRead: number;
|
|
27
|
+
readonly supersessions: number;
|
|
28
|
+
readonly numRules: number;
|
|
29
|
+
readonly numOccurrences: number;
|
|
30
|
+
readonly verdicts: number;
|
|
31
|
+
readonly rulesChanged: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const clip = (s: string, n = 300): string => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
35
|
+
|
|
36
|
+
export const supKey = (repo: string | null, source: string, where: string, ident: string): string => `sup:${hex12(repo, source, where, ident)}`;
|
|
37
|
+
export const verdictKey = (repo: string | null, path: string, kind: string, ident: string): string => `verdict:${hex12(repo, path, kind, ident)}`;
|
|
38
|
+
export const numKey = (repo: string | null, kind: string, where: string, num: string, ident: string): string => `num:${hex12(repo, kind, where, num, ident)}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Documents whose lines are not read: third-party and vendored material, test fixtures and data (a Unicode table is not
|
|
42
|
+
* the project speaking). Their versions are still recorded; only the line scans leave them out.
|
|
43
|
+
*/
|
|
44
|
+
export const NOT_LINE_SCANNED = /(^|\/)(third[_-]?party|thirdparty|vendor|vendored|node_modules|external|extern|fixtures?|testdata|test_data|__snapshots__|goldens?)\//i;
|
|
45
|
+
|
|
46
|
+
// ───────────────────────── pass 1: the lines of every content not read yet ─────────────────────────
|
|
47
|
+
|
|
48
|
+
function scanContentLines(db: DatabaseSync, repo: RepoHandle): number {
|
|
49
|
+
const todo = db.prepare(`SELECT DISTINCT d.path, d.blob FROM docs d
|
|
50
|
+
WHERE d.repo = ? AND NOT EXISTS (SELECT 1 FROM doc_scans s WHERE s.repo = d.repo AND s.path = d.path AND s.blob = d.blob AND s.what = 'lines')`).all(repo.id) as { path: string; blob: string }[];
|
|
51
|
+
const insSup = db.prepare(`INSERT OR IGNORE INTO supersedes (key, repo, source, path, pattern, target, text, ident, obsolete_list, list_heading, replaced, replacement, syntax, occurred_at, occurred_basis)
|
|
52
|
+
VALUES (?, ?, 'content', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'First observed')`);
|
|
53
|
+
const insVerdict = db.prepare(`INSERT OR IGNORE INTO verdicts (key, repo, path, kind, verdict, confidence, text, ident, occurred_at, occurred_basis)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', 'First observed')`);
|
|
55
|
+
const insNum = db.prepare(`INSERT OR IGNORE INTO nums (key, num, rule, kind, place, position, repo, path, line, context, ident, confidence, occurred_at, occurred_basis)
|
|
56
|
+
VALUES (?, ?, ?, 'doc', 'definition', ?, ?, ?, ?, ?, ?, 'stated', '', 'First observed')`);
|
|
57
|
+
const upNumPlace = db.prepare("UPDATE nums SET place = 'definition', position = ?, confidence = 'stated' WHERE key = ?");
|
|
58
|
+
const insItem = db.prepare('INSERT OR IGNORE INTO doc_items (repo, path, blob, tbl, key, line) VALUES (?, ?, ?, ?, ?, ?)');
|
|
59
|
+
const done = db.prepare("INSERT OR IGNORE INTO doc_scans (repo, path, blob, what) VALUES (?, ?, ?, 'lines')");
|
|
60
|
+
tx(db, () => {
|
|
61
|
+
for (const { path, blob } of todo) {
|
|
62
|
+
const text = NOT_LINE_SCANNED.test(path) ? null : getText(db, blobTextKey(repo.id, blob));
|
|
63
|
+
if (text !== null) {
|
|
64
|
+
for (const s of supersessionLines(text)) {
|
|
65
|
+
const ident = lineIdentity(s.text);
|
|
66
|
+
const key = supKey(repo.id, 'content', path, ident);
|
|
67
|
+
insSup.run(key, repo.id, path, s.pattern, s.target ? redact(s.target) : null, redact(s.text), ident, s.obsoleteList ? 1 : 0, s.listHeading,
|
|
68
|
+
s.replaced ? redact(s.replaced) : null, s.replacement ? redact(s.replacement) : null, s.syntax);
|
|
69
|
+
insItem.run(repo.id, path, blob, 'supersedes', key, s.line);
|
|
70
|
+
}
|
|
71
|
+
// A prompt is a task, not a report: its status field is the work's, not a verdict.
|
|
72
|
+
if (isReportLike(path, text) && arrangementKind(path) !== 'prompt') {
|
|
73
|
+
for (const v of verdictLines(text)) {
|
|
74
|
+
const ident = lineIdentity(v.text);
|
|
75
|
+
const key = verdictKey(repo.id, path, v.kind, ident);
|
|
76
|
+
insVerdict.run(key, repo.id, path, v.kind, v.verdict, v.confidence, redact(v.text), ident);
|
|
77
|
+
insItem.run(repo.id, path, blob, 'verdicts', key, v.line);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const d of definitionsInText(text)) {
|
|
81
|
+
const ident = lineIdentity(d.context);
|
|
82
|
+
const key = numKey(repo.id, 'doc', path, d.num, ident);
|
|
83
|
+
if (insNum.run(key, d.num, d.family, d.position, repo.id, path, d.line, redact(d.context), ident).changes === 0) upNumPlace.run(d.position, key);
|
|
84
|
+
insItem.run(repo.id, path, blob, 'nums', key, d.line ?? 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
done.run(repo.id, path, blob);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return todo.length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ───────────────────────── names and commit messages ─────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface NameItem { readonly kind: 'file-name' | 'branch' | 'worktree' | 'commit' | 'loose'; readonly where: string; readonly def: Definition }
|
|
96
|
+
|
|
97
|
+
/** Definitions in the repository's file names (all history), branch names and commit subjects. */
|
|
98
|
+
function nameDefinitions(db: DatabaseSync, repo: RepoHandle): NameItem[] {
|
|
99
|
+
const ordinary = (d: Definition) => !(familyOf(d.num)?.shape === 'two-letters' && isCommonPair(d.num));
|
|
100
|
+
const files: (NameItem & { readonly dir: string })[] = [];
|
|
101
|
+
for (const { path } of db.prepare('SELECT DISTINCT path FROM commit_files WHERE repo = ?').all(repo.id) as { path: string }[]) {
|
|
102
|
+
const segs = path.split('/');
|
|
103
|
+
for (const d of definitionsInPath(path)) {
|
|
104
|
+
if (!ordinary(d)) continue;
|
|
105
|
+
// The directory the numbered entry stands in: two letters count where several numbered entries stand together.
|
|
106
|
+
const at = segs.findIndex((s) => s.replace(/^\d{4}-\d{2}-\d{2}[_-]/, '').startsWith(d.num));
|
|
107
|
+
files.push({ kind: 'file-name', where: path, def: d, dir: segs.slice(0, Math.max(0, at)).join('/') });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const branches: NameItem[] = [];
|
|
111
|
+
for (const { name } of db.prepare('SELECT name FROM branches WHERE repo = ?').all(repo.id) as { name: string }[]) {
|
|
112
|
+
for (const d of definitionsInBranch(name)) if (ordinary(d)) branches.push({ kind: 'branch', where: name, def: d });
|
|
113
|
+
}
|
|
114
|
+
const subjects: NameItem[] = [];
|
|
115
|
+
for (const { hash, subject } of db.prepare('SELECT hash, subject FROM commits WHERE repo = ?').all(repo.id) as { hash: string; subject: string }[]) {
|
|
116
|
+
for (const d of definitionsInSubject(subject)) if (ordinary(d)) subjects.push({ kind: 'commit', where: hash, def: d });
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
...keepTwoLetterGroups(files.map((f) => ({ ...f, num: f.def.num })), (f) => f.dir).map(({ dir: _d, num: _n, ...f }) => f),
|
|
120
|
+
...keepTwoLetterGroups(branches.map((b) => ({ ...b, num: b.def.num })), () => 'branches').map(({ num: _n, ...b }) => b),
|
|
121
|
+
...keepTwoLetterGroups(subjects.map((s) => ({ ...s, num: s.def.num })), () => 'subjects').map(({ num: _n, ...s }) => s),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ───────────────────────── the rules ─────────────────────────
|
|
126
|
+
|
|
127
|
+
/** Recompute the numbering rules over every repository's definitions; returns the rules and whether they changed. */
|
|
128
|
+
export function computeNumRules(db: DatabaseSync, extra: readonly NameItem[]): { rules: Rule[]; changed: boolean } {
|
|
129
|
+
const defs = [
|
|
130
|
+
...(db.prepare("SELECT num, rule, path, position FROM nums WHERE place = 'definition' AND kind = 'doc'").all() as { num: string; rule: string; path: string; position: string | null }[])
|
|
131
|
+
.map((r) => ({ num: r.num, family: r.rule, position: r.position ?? 'document', where: r.path })),
|
|
132
|
+
...extra.map((e) => ({ num: e.def.num, family: e.def.family, position: e.def.position as string, where: e.where })),
|
|
133
|
+
];
|
|
134
|
+
const byFamily = new Map<string, { nums: Set<string>; positions: Map<string, Set<string>>; places: Map<string, number> }>();
|
|
135
|
+
for (const d of defs) {
|
|
136
|
+
const f = byFamily.get(d.family) ?? { nums: new Set<string>(), positions: new Map<string, Set<string>>(), places: new Map<string, number>() };
|
|
137
|
+
f.nums.add(d.num);
|
|
138
|
+
const pos = f.positions.get(d.position) ?? new Set<string>();
|
|
139
|
+
pos.add(d.num);
|
|
140
|
+
f.positions.set(d.position, pos);
|
|
141
|
+
f.places.set(d.where, (f.places.get(d.where) ?? 0) + 1);
|
|
142
|
+
byFamily.set(d.family, f);
|
|
143
|
+
}
|
|
144
|
+
const rules: Rule[] = [];
|
|
145
|
+
const rows: { rule: string; shape: string; prefix: string; basis: string; examples: string; defined: number }[] = [];
|
|
146
|
+
for (const [family, f] of [...byFamily].sort((a, b) => b[1].nums.size - a[1].nums.size)) {
|
|
147
|
+
if (f.nums.size < MIN_DEFINED) continue;
|
|
148
|
+
const t = familyOf([...f.nums][0]!);
|
|
149
|
+
if (!t) continue;
|
|
150
|
+
const sorted = [...f.nums].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
151
|
+
const topPlaces = [...f.places].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([w, n]) => `${w.length > 60 ? `…${w.slice(-60)}` : w} (${n})`).join(', ');
|
|
152
|
+
const basis = `${f.nums.size} different numbers defined — ${[...f.positions].map(([p, s]) => `${p}: ${s.size}`).join('; ')}; most in ${topPlaces}.`;
|
|
153
|
+
rules.push({ family, shape: t.shape, defined: new Set(f.nums) });
|
|
154
|
+
rows.push({ rule: family, shape: t.shape, prefix: family.replace(/<n>$/, ''), basis, examples: sorted.length > 8 ? `${sorted.slice(0, 4).join(', ')} … ${sorted.slice(-3).join(', ')}` : sorted.join(', '), defined: f.nums.size });
|
|
155
|
+
}
|
|
156
|
+
const signature = rules.map((r) => (r.shape === 'two-letters' ? `${r.family}:${[...r.defined].sort().join(',')}` : r.family)).sort().join('|');
|
|
157
|
+
const before = (db.prepare("SELECT value FROM state WHERE key = 'numRules'").get() as { value: string } | undefined)?.value ?? null;
|
|
158
|
+
tx(db, () => {
|
|
159
|
+
db.prepare('DELETE FROM num_rules').run();
|
|
160
|
+
const ins = db.prepare('INSERT INTO num_rules (rule, shape, prefix, basis, examples, defined) VALUES (?, ?, ?, ?, ?, ?)');
|
|
161
|
+
for (const r of rows) ins.run(r.rule, r.shape, r.prefix, r.basis, r.examples, r.defined);
|
|
162
|
+
db.prepare("INSERT OR REPLACE INTO state (key, value) VALUES ('numRules', ?)").run(signature);
|
|
163
|
+
});
|
|
164
|
+
return { rules, changed: before !== signature };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The rules as stored (for a scan that did not recompute them). */
|
|
168
|
+
export function storedRules(db: DatabaseSync): Rule[] {
|
|
169
|
+
const rows = db.prepare('SELECT rule, shape FROM num_rules').all() as { rule: string; shape: Rule['shape'] }[];
|
|
170
|
+
const defined = new Map<string, Set<string>>();
|
|
171
|
+
for (const r of db.prepare("SELECT DISTINCT num, rule FROM nums WHERE place = 'definition'").all() as { num: string; rule: string }[]) {
|
|
172
|
+
const s = defined.get(r.rule) ?? new Set<string>();
|
|
173
|
+
s.add(r.num);
|
|
174
|
+
defined.set(r.rule, s);
|
|
175
|
+
}
|
|
176
|
+
return rows.map((r) => ({ family: r.rule, shape: r.shape, defined: defined.get(r.rule) ?? new Set() }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ───────────────────────── pass 2: mentions of the recognised numbers ─────────────────────────
|
|
180
|
+
|
|
181
|
+
function scanMentions(db: DatabaseSync, repo: RepoHandle, rules: readonly Rule[], names: readonly NameItem[], now: string): void {
|
|
182
|
+
const match = mentionMatcher(rules);
|
|
183
|
+
const ruleOf = (num: string) => familyOf(num)?.family ?? 'unknown';
|
|
184
|
+
const insItem = db.prepare('INSERT OR IGNORE INTO doc_items (repo, path, blob, tbl, key, line) VALUES (?, ?, ?, ?, ?, ?)');
|
|
185
|
+
const insNum = db.prepare(`INSERT OR IGNORE INTO nums (key, num, rule, kind, place, repo, path, commit_hash, line, context, ident, confidence, first_ms, occurred_at, occurred_basis, occurred_anchor, undated)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
187
|
+
// Documents: every content not read for mentions under this rule set.
|
|
188
|
+
if (match) {
|
|
189
|
+
const todo = db.prepare(`SELECT DISTINCT d.path, d.blob FROM docs d
|
|
190
|
+
WHERE d.repo = ? AND NOT EXISTS (SELECT 1 FROM doc_scans s WHERE s.repo = d.repo AND s.path = d.path AND s.blob = d.blob AND s.what = 'mentions')`).all(repo.id) as { path: string; blob: string }[];
|
|
191
|
+
const done = db.prepare("INSERT OR IGNORE INTO doc_scans (repo, path, blob, what) VALUES (?, ?, ?, 'mentions')");
|
|
192
|
+
tx(db, () => {
|
|
193
|
+
for (const { path, blob } of todo) {
|
|
194
|
+
const text = NOT_LINE_SCANNED.test(path) ? null : getText(db, blobTextKey(repo.id, blob));
|
|
195
|
+
if (text !== null) {
|
|
196
|
+
for (const m of match(text)) {
|
|
197
|
+
const ident = lineIdentity(m.context);
|
|
198
|
+
const key = numKey(repo.id, 'doc', path, m.num, ident);
|
|
199
|
+
insNum.run(key, m.num, m.family, 'doc', 'mention', repo.id, path, null, m.line, redact(m.context), ident, m.confidence, null, '', 'First observed', null, 0);
|
|
200
|
+
insItem.run(repo.id, path, blob, 'nums', key, m.line);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
done.run(repo.id, path, blob);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Commit messages not read yet: each commit is its own entry, dated by the commit.
|
|
208
|
+
const commits = db.prepare('SELECT hash, author_at, author_ms, subject, body FROM commits WHERE repo = ? AND scanned = 0').all(repo.id) as { hash: string; author_at: string; author_ms: number; subject: string; body: string }[];
|
|
209
|
+
const insSup = db.prepare(`INSERT OR IGNORE INTO supersedes (key, repo, source, path, pattern, target, text, ident, replaced, replacement, syntax, first_commit, first_line, first_ms, occurred_at, occurred_basis, occurred_anchor, last_commit, last_ms)
|
|
210
|
+
VALUES (?, ?, 'commit', NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'Commit', ?, ?, ?)`);
|
|
211
|
+
const defsBySubject = new Map<string, Definition[]>();
|
|
212
|
+
for (const n of names) if (n.kind === 'commit') defsBySubject.set(n.where, [...(defsBySubject.get(n.where) ?? []), n.def]);
|
|
213
|
+
tx(db, () => {
|
|
214
|
+
for (const c of commits) {
|
|
215
|
+
const at = materialTime(c.author_at) ?? c.author_at;
|
|
216
|
+
const anchor = `commit ${c.hash.slice(0, 10)}`;
|
|
217
|
+
const lines = `${c.subject}\n${c.body}`.split('\n');
|
|
218
|
+
for (let i = 0; i < lines.length; i++) {
|
|
219
|
+
const t = lines[i]!.trim();
|
|
220
|
+
if (!t || t.length > 1500) continue;
|
|
221
|
+
for (const s of supersessionLines(t)) {
|
|
222
|
+
const ident = lineIdentity(t);
|
|
223
|
+
insSup.run(supKey(repo.id, 'commit', c.hash, ident), repo.id, s.pattern, s.target ? redact(s.target) : null, redact(clip(t)), ident,
|
|
224
|
+
s.replaced ? redact(s.replaced) : null, s.replacement ? redact(s.replacement) : null, s.syntax, c.hash, i + 1, c.author_ms, at, anchor, c.hash, c.author_ms);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const defined = new Set((defsBySubject.get(c.hash) ?? []).map((d) => d.num));
|
|
228
|
+
if (match) {
|
|
229
|
+
for (const m of match(`${c.subject}\n${c.body}`)) {
|
|
230
|
+
const ident = lineIdentity(m.context);
|
|
231
|
+
const place = m.line === 1 && defined.has(m.num) ? 'definition' : 'mention';
|
|
232
|
+
insNum.run(numKey(repo.id, 'commit', c.hash, m.num, ident), m.num, m.family, 'commit', place, repo.id, null, c.hash, m.line, redact(m.context), ident,
|
|
233
|
+
place === 'definition' ? 'stated' : m.confidence, c.author_ms, at, 'Commit', anchor, 0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const num of defined) {
|
|
237
|
+
const ident = lineIdentity(c.subject);
|
|
238
|
+
insNum.run(numKey(repo.id, 'commit', c.hash, num, ident), num, ruleOf(num), 'commit', 'definition', repo.id, null, c.hash, 1, redact(clip(c.subject, 200)), ident, 'stated', c.author_ms, at, 'Commit', anchor, 0);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
db.prepare('UPDATE commits SET scanned = 1 WHERE repo = ? AND scanned = 0').run(repo.id);
|
|
242
|
+
});
|
|
243
|
+
// Names: file names (all history), branches and worktrees, recomputed each time (cheap; the entry ids are stable).
|
|
244
|
+
tx(db, () => {
|
|
245
|
+
db.prepare("DELETE FROM nums WHERE repo = ? AND kind IN ('file-name', 'branch', 'worktree')").run(repo.id);
|
|
246
|
+
db.prepare("DELETE FROM supersedes WHERE repo = ? AND source = 'filename'").run(repo.id);
|
|
247
|
+
const firstTouch = new Map((db.prepare(`SELECT cf.path, min(c.author_ms) ms FROM commit_files cf JOIN commits c ON c.repo = cf.repo AND c.hash = cf.hash WHERE cf.repo = ? GROUP BY cf.path`).all(repo.id) as { path: string; ms: number }[]).map((r) => [r.path, r.ms]));
|
|
248
|
+
const firstCommit = db.prepare(`SELECT c.hash, c.author_at FROM commit_files cf JOIN commits c ON c.repo = cf.repo AND c.hash = cf.hash WHERE cf.repo = ? AND cf.path = ? ORDER BY c.author_ms, c.hash LIMIT 1`);
|
|
249
|
+
const inHead = new Set((db.prepare('SELECT path FROM code_files WHERE repo = ?').all(repo.id) as { path: string }[]).map((r) => r.path));
|
|
250
|
+
const whenFirst = (path: string): { at: string; hash: string | null; ms: number | null } | null => {
|
|
251
|
+
if (!firstTouch.has(path)) return null;
|
|
252
|
+
const r = firstCommit.get(repo.id, path) as { hash: string; author_at: string } | undefined;
|
|
253
|
+
return r ? { at: materialTime(r.author_at) ?? r.author_at, hash: r.hash, ms: firstTouch.get(path) ?? null } : null;
|
|
254
|
+
};
|
|
255
|
+
const defs = new Map<string, Set<string>>();
|
|
256
|
+
for (const n of names) if (n.kind === 'file-name' || n.kind === 'branch') defs.set(`${n.kind}:${n.where}`, new Set([...(defs.get(`${n.kind}:${n.where}`) ?? []), n.def.num]));
|
|
257
|
+
const put = (kind: 'file-name' | 'branch' | 'worktree', where: string, num: string, when: { at: string; hash: string | null; ms: number | null } | null, current: boolean) => {
|
|
258
|
+
const place = defs.get(`${kind}:${where}`)?.has(num) ? 'definition' : 'mention';
|
|
259
|
+
const occ: Occurred = when ? { at: when.at, basis: 'Commit', anchor: when.hash ? `commit ${when.hash.slice(0, 10)}` : null } : { at: now, basis: 'First observed', anchor: `${kind} ${where}`, undated: true };
|
|
260
|
+
insNum.run(numKey(repo.id, kind, where, num, where), num, ruleOf(num), kind, place, repo.id, kind === 'file-name' ? where : null, when?.hash ?? null, null, where, where, 'stated',
|
|
261
|
+
when?.ms ?? null, occ.at, occ.basis, occ.anchor, occ.undated ? 1 : 0);
|
|
262
|
+
if (current) db.prepare('UPDATE nums SET current = 1 WHERE key = ?').run(numKey(repo.id, kind, where, num, where));
|
|
263
|
+
};
|
|
264
|
+
const inName = nameMatcher(rules);
|
|
265
|
+
const seenDirs = new Set<string>();
|
|
266
|
+
for (const path of firstTouch.keys()) {
|
|
267
|
+
const nums = new Set([...(defs.get(`file-name:${path}`) ?? []), ...inName(path)]);
|
|
268
|
+
for (const num of nums) put('file-name', path, num, whenFirst(path), inHead.has(path));
|
|
269
|
+
const s = supersessionInPath(path);
|
|
270
|
+
if (s && !seenDirs.has(s.at)) {
|
|
271
|
+
seenDirs.add(s.at);
|
|
272
|
+
const under = [...firstTouch.entries()].filter(([p]) => p === s.at || p.startsWith(s.at)).sort((a, b) => a[1] - b[1])[0];
|
|
273
|
+
const w = under ? whenFirst(under[0]) : null;
|
|
274
|
+
const ident = lineIdentity(s.at);
|
|
275
|
+
db.prepare(`INSERT OR IGNORE INTO supersedes (key, repo, source, path, pattern, target, text, ident, replaced, replacement, syntax, first_commit, first_ms, occurred_at, occurred_basis, occurred_anchor, undated, current)
|
|
276
|
+
VALUES (?, ?, 'filename', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
277
|
+
.run(supKey(repo.id, 'filename', s.at, ident), repo.id, s.at, s.pattern, s.target, s.at, ident, s.at, s.target, 'filename', w?.hash ?? null, w?.ms ?? null, w?.at ?? now, w ? 'Commit' : 'First observed', w?.hash ? `commit ${w.hash.slice(0, 10)}` : null, w ? 0 : 1,
|
|
278
|
+
[...inHead].some((p) => p === s.at || p.startsWith(s.at)) ? 1 : 0);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const tipOf = db.prepare('SELECT b.tip, c.author_at, c.author_ms FROM branches b LEFT JOIN commits c ON c.repo = b.repo AND c.hash = b.tip WHERE b.repo = ? AND b.name = ?');
|
|
282
|
+
for (const { name } of db.prepare('SELECT name FROM branches WHERE repo = ?').all(repo.id) as { name: string }[]) {
|
|
283
|
+
const nums = new Set([...(defs.get(`branch:${name}`) ?? []), ...inName(name)]);
|
|
284
|
+
const tip = tipOf.get(repo.id, name) as { tip: string; author_at: string | null; author_ms: number | null } | undefined;
|
|
285
|
+
for (const num of nums) put('branch', name, num, tip?.author_at ? { at: materialTime(tip.author_at) ?? tip.author_at, hash: tip.tip, ms: tip.author_ms } : null, true);
|
|
286
|
+
}
|
|
287
|
+
for (const { path, branch, head } of db.prepare('SELECT path, branch, head FROM worktrees WHERE repo = ?').all(repo.id) as { path: string; branch: string | null; head: string | null }[]) {
|
|
288
|
+
const base = path.split(/[\\/]/).pop() ?? path;
|
|
289
|
+
const nums = new Set(inName(base));
|
|
290
|
+
const h = head ? db.prepare('SELECT author_at, author_ms FROM commits WHERE repo = ? AND hash = ?').get(repo.id, head) as { author_at: string; author_ms: number } | undefined : undefined;
|
|
291
|
+
for (const num of nums) put('worktree', path, num, h ? { at: materialTime(h.author_at) ?? h.author_at, hash: head, ms: h.author_ms } : null, true);
|
|
292
|
+
void branch;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ───────────────────────── when each line happened ─────────────────────────
|
|
298
|
+
|
|
299
|
+
interface ItemVersion { key: string; path: string; commit_hash: string; at: string; at_ms: number; import_root: number; change: string; prev_path: string | null; blob: string; line: number }
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* First and last version, current flag and occurred time of every document line item of a repository. The first version
|
|
303
|
+
* of a line in a moved document is looked up at the old path too (the same line there, found by its normal form).
|
|
304
|
+
*/
|
|
305
|
+
function timeDocItems(db: DatabaseSync, repo: RepoHandle, now: string): void {
|
|
306
|
+
const rows = db.prepare(`SELECT di.key, d.path, d.commit_hash, d.at, d.at_ms, d.import_root, d.change, d.prev_path, d.blob, di.line
|
|
307
|
+
FROM doc_items di JOIN docs d ON d.repo = di.repo AND d.path = di.path AND d.blob = di.blob
|
|
308
|
+
WHERE di.repo = ? ORDER BY di.key, d.at_ms, d.commit_hash`).all(repo.id) as unknown as ItemVersion[];
|
|
309
|
+
const current = new Map((db.prepare(`SELECT di.key, di.line FROM doc_items di JOIN code_files cf ON cf.repo = di.repo AND cf.path = di.path AND cf.blob = di.blob WHERE di.repo = ?`).all(repo.id) as { key: string; line: number }[]).map((r) => [r.key, r.line]));
|
|
310
|
+
const tableOf = new Map((db.prepare('SELECT DISTINCT key, tbl FROM doc_items WHERE repo = ?').all(repo.id) as { key: string; tbl: string }[]).map((r) => [r.key, r.tbl]));
|
|
311
|
+
const span = new Map<string, { first: ItemVersion; last: ItemVersion }>();
|
|
312
|
+
for (const r of rows) {
|
|
313
|
+
const s = span.get(r.key);
|
|
314
|
+
if (!s) span.set(r.key, { first: r, last: r });
|
|
315
|
+
else s.last = r;
|
|
316
|
+
}
|
|
317
|
+
// Moved documents: a first version that is a rename or copy inherits the old path's first appearance of the same line.
|
|
318
|
+
const identOf = (tbl: string, key: string): { ident: string; extra: string } | null => {
|
|
319
|
+
const r = db.prepare(`SELECT ident${tbl === 'nums' ? ', num AS extra' : tbl === 'verdicts' ? ', kind AS extra' : ", '' AS extra"} FROM ${tbl} WHERE key = ?`).get(key) as { ident: string; extra: string } | undefined;
|
|
320
|
+
return r ?? null;
|
|
321
|
+
};
|
|
322
|
+
const keyAt = (tbl: string, path: string, id: { ident: string; extra: string }) =>
|
|
323
|
+
tbl === 'supersedes' ? supKey(repo.id, 'content', path, id.ident) : tbl === 'verdicts' ? verdictKey(repo.id, path, id.extra, id.ident) : numKey(repo.id, 'doc', path, id.extra, id.ident);
|
|
324
|
+
const resolveFirst = (key: string, depth = 0): ItemVersion | null => {
|
|
325
|
+
const s = span.get(key);
|
|
326
|
+
if (!s) return null;
|
|
327
|
+
const f = s.first;
|
|
328
|
+
if (depth < 8 && (f.change === 'Renamed' || f.change === 'Copied') && f.prev_path) {
|
|
329
|
+
const tbl = tableOf.get(key);
|
|
330
|
+
const id = tbl ? identOf(tbl, key) : null;
|
|
331
|
+
if (tbl && id) {
|
|
332
|
+
const older = resolveFirst(keyAt(tbl, f.prev_path, id), depth + 1);
|
|
333
|
+
if (older && older.at_ms <= f.at_ms) return older;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return f;
|
|
337
|
+
};
|
|
338
|
+
const textCache = new Map<string, string | null>();
|
|
339
|
+
const textOf = (blob: string) => {
|
|
340
|
+
if (!textCache.has(blob)) { if (textCache.size > 400) textCache.clear(); textCache.set(blob, getText(db, blobTextKey(repo.id, blob))); }
|
|
341
|
+
return textCache.get(blob)!;
|
|
342
|
+
};
|
|
343
|
+
const ups: Record<string, ReturnType<DatabaseSync['prepare']>> = {};
|
|
344
|
+
for (const tbl of ['supersedes', 'verdicts', 'nums']) {
|
|
345
|
+
ups[tbl] = db.prepare(`UPDATE ${tbl} SET ${tbl === 'nums' ? 'commit_hash = ?, line = ?' : 'first_commit = ?, first_line = ?'}, first_ms = ?, occurred_at = ?, occurred_basis = ?, occurred_anchor = ?,
|
|
346
|
+
other_at = ?, other_basis = ?, undated = ?, last_commit = ?, last_ms = ?, current = ?${tbl === 'nums' ? '' : ', current_line = ?'} WHERE key = ?`);
|
|
347
|
+
}
|
|
348
|
+
tx(db, () => {
|
|
349
|
+
for (const [key, s] of span) {
|
|
350
|
+
const tbl = tableOf.get(key);
|
|
351
|
+
if (!tbl) continue;
|
|
352
|
+
const first = resolveFirst(key) ?? s.first;
|
|
353
|
+
const text = textOf(first.blob) ?? '';
|
|
354
|
+
const importRoot = first.import_root === 1;
|
|
355
|
+
const occ = occurredOfLine({
|
|
356
|
+
commit: first.commit_hash, commitAt: first.at, importRoot, path: first.path, firstSeen: first.at,
|
|
357
|
+
fileTime: importRoot ? fileTimeCached(repo.path, first.path) : null,
|
|
358
|
+
}, datesOf(`${repo.id}:${first.blob}`, text), first.line);
|
|
359
|
+
const cur = current.get(key);
|
|
360
|
+
const args: (string | number | null)[] = [first.commit_hash, first.line, first.at_ms, occ.at, occ.basis, occ.anchor, occ.other?.at ?? null, occ.other?.basis ?? null, occ.undated ? 1 : 0,
|
|
361
|
+
s.last.commit_hash, s.last.at_ms, cur !== undefined ? 1 : 0];
|
|
362
|
+
if (tbl !== 'nums') args.push(cur ?? null);
|
|
363
|
+
args.push(key);
|
|
364
|
+
ups[tbl]!.run(...args);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
void now;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const fileTimes = new Map<string, string | null>();
|
|
371
|
+
function fileTimeCached(root: string, rel: string): string | null {
|
|
372
|
+
const k = `${root}|${rel}`;
|
|
373
|
+
if (!fileTimes.has(k)) fileTimes.set(k, versionTime(root, rel, '', '', true, '').fileTime);
|
|
374
|
+
return fileTimes.get(k)!;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ───────────────────────── one repository ─────────────────────────
|
|
378
|
+
|
|
379
|
+
/** Pass 1 for one repository (before the rules are computed over all of them). */
|
|
380
|
+
export function scanTextsFirstPass(db: DatabaseSync, repo: RepoHandle): { contents: number; names: NameItem[] } {
|
|
381
|
+
// CZ: when the way definitions are read changes (numbering.ts NUMBERING_VERSION), the lines of every content are read
|
|
382
|
+
// again once — each line keeps its entry (the inserts are by the line's own key), and the new definitions join them.
|
|
383
|
+
const key = `numbering:${repo.id}`;
|
|
384
|
+
if (getState(db, key) !== NUMBERING_VERSION) {
|
|
385
|
+
tx(db, () => { db.prepare("DELETE FROM doc_scans WHERE repo = ? AND what = 'lines'").run(repo.id); setState(db, key, NUMBERING_VERSION); });
|
|
386
|
+
}
|
|
387
|
+
const contents = scanContentLines(db, repo);
|
|
388
|
+
return { contents, names: nameDefinitions(db, repo) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Pass 2 for one repository, once the rules are known: mentions, then the timing of every line item. */
|
|
392
|
+
export function scanTextsSecondPass(db: DatabaseSync, repo: RepoHandle, rules: readonly Rule[], rulesChanged: boolean, names: readonly NameItem[], now: string): void {
|
|
393
|
+
if (rulesChanged) {
|
|
394
|
+
tx(db, () => {
|
|
395
|
+
db.prepare("DELETE FROM doc_items WHERE repo = ? AND tbl = 'nums' AND key IN (SELECT key FROM nums WHERE repo = ? AND place = 'mention' AND kind = 'doc')").run(repo.id, repo.id);
|
|
396
|
+
db.prepare("DELETE FROM nums WHERE repo = ? AND place = 'mention'").run(repo.id);
|
|
397
|
+
db.prepare("DELETE FROM nums WHERE repo = ? AND kind = 'commit'").run(repo.id);
|
|
398
|
+
db.prepare("DELETE FROM doc_scans WHERE repo = ? AND what = 'mentions'").run(repo.id);
|
|
399
|
+
db.prepare("DELETE FROM supersedes WHERE repo = ? AND source = 'commit'").run(repo.id);
|
|
400
|
+
db.prepare('UPDATE commits SET scanned = 0 WHERE repo = ?').run(repo.id);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
scanMentions(db, repo, rules, names, now);
|
|
404
|
+
timeDocItems(db, repo, now);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function textStats(db: DatabaseSync, repo: string, contents: number, rulesChanged: boolean): TextScanStats {
|
|
408
|
+
const c = (sql: string) => (db.prepare(sql).get(repo) as { c: number }).c;
|
|
409
|
+
return {
|
|
410
|
+
contentsRead: contents,
|
|
411
|
+
supersessions: c('SELECT count(*) c FROM supersedes WHERE repo = ?'),
|
|
412
|
+
numRules: (db.prepare('SELECT count(*) c FROM num_rules').get() as { c: number }).c,
|
|
413
|
+
numOccurrences: c('SELECT count(*) c FROM nums WHERE repo = ?'),
|
|
414
|
+
verdicts: c('SELECT count(*) c FROM verdicts WHERE repo = ?'),
|
|
415
|
+
rulesChanged,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ───────────────────────── material outside version control ─────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* A document outside version control (Spec §3.7, AC-15): its lines are dated by the date written in the text, else by
|
|
423
|
+
* the file's own time (`File time`, a weak basis). A changed file's rows are replaced; the entry ids stay the same for
|
|
424
|
+
* the same lines.
|
|
425
|
+
*/
|
|
426
|
+
export function scanLooseDocument(db: DatabaseSync, path: string, text: string, fileTime: string, rules: readonly Rule[]): void {
|
|
427
|
+
const dates = datesOf(`loose:${path}:${fileTime}`, text);
|
|
428
|
+
const occ = (line: number | null): Occurred => {
|
|
429
|
+
const w = line === null ? dates.document : dates.at(line);
|
|
430
|
+
return w ? { at: w.at, basis: 'Written in text', anchor: `${path}:${w.line}`, other: { at: fileTime, basis: 'File time', anchor: path } } : { at: fileTime, basis: 'File time', anchor: path };
|
|
431
|
+
};
|
|
432
|
+
const put = (tbl: string, sql: string, args: (string | number | null)[]) => { void tbl; db.prepare(sql).run(...args); };
|
|
433
|
+
db.prepare("DELETE FROM supersedes WHERE repo IS NULL AND source = 'loose' AND path = ?").run(path);
|
|
434
|
+
db.prepare("DELETE FROM verdicts WHERE repo IS NULL AND path = ?").run(path);
|
|
435
|
+
db.prepare("DELETE FROM nums WHERE repo IS NULL AND kind = 'loose' AND path = ?").run(path);
|
|
436
|
+
for (const s of supersessionLines(text)) {
|
|
437
|
+
const o = occ(s.line);
|
|
438
|
+
const ident = lineIdentity(s.text);
|
|
439
|
+
put('supersedes', `INSERT OR IGNORE INTO supersedes (key, repo, source, path, pattern, target, text, ident, obsolete_list, list_heading, replaced, replacement, syntax, first_line, first_ms, occurred_at, occurred_basis, occurred_anchor, other_at, other_basis, current, current_line)
|
|
440
|
+
VALUES (?, NULL, 'loose', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
|
441
|
+
[supKey(null, 'loose', path, ident), path, s.pattern, s.target ? redact(s.target) : null, redact(s.text), ident, s.obsoleteList ? 1 : 0, s.listHeading,
|
|
442
|
+
s.replaced ? redact(s.replaced) : null, s.replacement ? redact(s.replacement) : null, s.syntax, s.line, msOf(o.at), o.at, o.basis, o.anchor, o.other?.at ?? null, o.other?.basis ?? null, s.line]);
|
|
443
|
+
}
|
|
444
|
+
if (isReportLike(path.split('\\').join('/'), text) && arrangementKind(path) !== 'prompt') {
|
|
445
|
+
for (const v of verdictLines(text)) {
|
|
446
|
+
const o = occ(v.line);
|
|
447
|
+
const ident = lineIdentity(v.text);
|
|
448
|
+
put('verdicts', `INSERT OR IGNORE INTO verdicts (key, repo, path, kind, verdict, confidence, text, ident, first_line, first_ms, occurred_at, occurred_basis, occurred_anchor, other_at, other_basis, current, current_line)
|
|
449
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
|
450
|
+
[verdictKey(null, path, v.kind, ident), path, v.kind, v.verdict, v.confidence, redact(v.text), ident, v.line, msOf(o.at), o.at, o.basis, o.anchor, o.other?.at ?? null, o.other?.basis ?? null, v.line]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const defs = definitionsInText(text);
|
|
454
|
+
const defined = new Set(defs.map((d) => `${d.num}:${d.line}`));
|
|
455
|
+
const match = mentionMatcher(rules);
|
|
456
|
+
const all = [...defs.map((d) => ({ num: d.num, family: d.family, line: d.line ?? 1, context: d.context, confidence: 'stated' as const })), ...(match ? match(text) : [])];
|
|
457
|
+
for (const m of all) {
|
|
458
|
+
const o = occ(m.line);
|
|
459
|
+
const ident = lineIdentity(m.context);
|
|
460
|
+
put('nums', `INSERT OR IGNORE INTO nums (key, num, rule, kind, place, repo, path, line, context, ident, confidence, first_ms, occurred_at, occurred_basis, occurred_anchor, other_at, other_basis, current)
|
|
461
|
+
VALUES (?, ?, ?, 'loose', ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
|
462
|
+
[numKey(null, 'loose', path, m.num, ident), m.num, m.family, defined.has(`${m.num}:${m.line}`) ? 'definition' : 'mention', path, m.line, redact(m.context), ident, m.confidence, msOf(o.at), o.at, o.basis, o.anchor, o.other?.at ?? null, o.other?.basis ?? null]);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export { isDocumentPath };
|