@plebeiantech/lat.md 0.12.3-fork.1
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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/src/cli/check-frontmatter.d.ts +12 -0
- package/dist/src/cli/check-frontmatter.js +44 -0
- package/dist/src/cli/check-mode.d.ts +6 -0
- package/dist/src/cli/check-mode.js +303 -0
- package/dist/src/cli/check-status.d.ts +74 -0
- package/dist/src/cli/check-status.js +217 -0
- package/dist/src/cli/check.d.ts +45 -0
- package/dist/src/cli/check.js +777 -0
- package/dist/src/cli/checklist-menu.d.ts +12 -0
- package/dist/src/cli/checklist-menu.js +111 -0
- package/dist/src/cli/comment-guard.d.ts +16 -0
- package/dist/src/cli/comment-guard.js +111 -0
- package/dist/src/cli/comment-reminder.d.ts +62 -0
- package/dist/src/cli/comment-reminder.js +210 -0
- package/dist/src/cli/context.d.ts +11 -0
- package/dist/src/cli/context.js +58 -0
- package/dist/src/cli/expand.d.ts +8 -0
- package/dist/src/cli/expand.js +94 -0
- package/dist/src/cli/gen-index.d.ts +45 -0
- package/dist/src/cli/gen-index.js +235 -0
- package/dist/src/cli/gen.d.ts +6 -0
- package/dist/src/cli/gen.js +42 -0
- package/dist/src/cli/graph.d.ts +7 -0
- package/dist/src/cli/graph.js +47 -0
- package/dist/src/cli/hook.d.ts +1 -0
- package/dist/src/cli/hook.js +432 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +307 -0
- package/dist/src/cli/init.d.ts +9 -0
- package/dist/src/cli/init.js +1031 -0
- package/dist/src/cli/link-scheme.d.ts +54 -0
- package/dist/src/cli/link-scheme.js +101 -0
- package/dist/src/cli/locate.d.ts +2 -0
- package/dist/src/cli/locate.js +17 -0
- package/dist/src/cli/refs.d.ts +22 -0
- package/dist/src/cli/refs.js +229 -0
- package/dist/src/cli/reindex.d.ts +13 -0
- package/dist/src/cli/reindex.js +136 -0
- package/dist/src/cli/search.d.ts +36 -0
- package/dist/src/cli/search.js +217 -0
- package/dist/src/cli/section.d.ts +43 -0
- package/dist/src/cli/section.js +252 -0
- package/dist/src/cli/select-menu.d.ts +13 -0
- package/dist/src/cli/select-menu.js +100 -0
- package/dist/src/cli/templates.d.ts +1 -0
- package/dist/src/cli/templates.js +15 -0
- package/dist/src/cli/ui.d.ts +11 -0
- package/dist/src/cli/ui.js +34 -0
- package/dist/src/code-refs.d.ts +17 -0
- package/dist/src/code-refs.js +277 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +83 -0
- package/dist/src/context.d.ts +23 -0
- package/dist/src/context.js +11 -0
- package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
- package/dist/src/extensions/wiki-link/index.d.ts +4 -0
- package/dist/src/extensions/wiki-link/index.js +3 -0
- package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
- package/dist/src/extensions/wiki-link/syntax.js +121 -0
- package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
- package/dist/src/extensions/wiki-link/types.d.ts +34 -0
- package/dist/src/extensions/wiki-link/types.js +1 -0
- package/dist/src/format.d.ts +8 -0
- package/dist/src/format.js +43 -0
- package/dist/src/graph-export.d.ts +60 -0
- package/dist/src/graph-export.js +309 -0
- package/dist/src/init-version.d.ts +10 -0
- package/dist/src/init-version.js +49 -0
- package/dist/src/knowledge/bd.d.ts +2 -0
- package/dist/src/knowledge/bd.js +107 -0
- package/dist/src/knowledge/claude-memory.d.ts +13 -0
- package/dist/src/knowledge/claude-memory.js +204 -0
- package/dist/src/knowledge/cq.d.ts +2 -0
- package/dist/src/knowledge/cq.js +74 -0
- package/dist/src/knowledge/index.d.ts +49 -0
- package/dist/src/knowledge/index.js +271 -0
- package/dist/src/knowledge/ranking.d.ts +17 -0
- package/dist/src/knowledge/ranking.js +62 -0
- package/dist/src/knowledge/session.d.ts +46 -0
- package/dist/src/knowledge/session.js +203 -0
- package/dist/src/knowledge/types.d.ts +47 -0
- package/dist/src/knowledge/types.js +10 -0
- package/dist/src/lattice.d.ts +108 -0
- package/dist/src/lattice.js +816 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +58 -0
- package/dist/src/parser.d.ts +3 -0
- package/dist/src/parser.js +18 -0
- package/dist/src/search/db.d.ts +16 -0
- package/dist/src/search/db.js +54 -0
- package/dist/src/search/embedder.d.ts +25 -0
- package/dist/src/search/embedder.js +58 -0
- package/dist/src/search/index.d.ts +9 -0
- package/dist/src/search/index.js +66 -0
- package/dist/src/search/search.d.ts +9 -0
- package/dist/src/search/search.js +16 -0
- package/dist/src/source-parser.d.ts +27 -0
- package/dist/src/source-parser.js +916 -0
- package/dist/src/untrusted.d.ts +24 -0
- package/dist/src/untrusted.js +97 -0
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +39 -0
- package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
- package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
- package/dist/src/view/client/index.html +14 -0
- package/dist/src/view/diagnostics.d.ts +5 -0
- package/dist/src/view/diagnostics.js +171 -0
- package/dist/src/view/git-diff.d.ts +3 -0
- package/dist/src/view/git-diff.js +255 -0
- package/dist/src/view/git.d.ts +22 -0
- package/dist/src/view/git.js +193 -0
- package/dist/src/view/highlight.d.ts +2 -0
- package/dist/src/view/highlight.js +3767 -0
- package/dist/src/view/markdown.d.ts +22 -0
- package/dist/src/view/markdown.js +322 -0
- package/dist/src/view/protocol.d.ts +89 -0
- package/dist/src/view/protocol.js +1 -0
- package/dist/src/view/references.d.ts +62 -0
- package/dist/src/view/references.js +345 -0
- package/dist/src/view/repository.d.ts +12 -0
- package/dist/src/view/repository.js +167 -0
- package/dist/src/view/search.d.ts +14 -0
- package/dist/src/view/search.js +64 -0
- package/dist/src/view/server.d.ts +20 -0
- package/dist/src/view/server.js +233 -0
- package/dist/src/view/store.d.ts +57 -0
- package/dist/src/view/store.js +404 -0
- package/dist/src/walk.d.ts +18 -0
- package/dist/src/walk.js +27 -0
- package/package.json +85 -0
- package/templates/AGENTS.md +118 -0
- package/templates/README +1 -0
- package/templates/cursor-rules.md +77 -0
- package/templates/init/lat.md +1 -0
- package/templates/logo-dark.svg +182 -0
- package/templates/logo-light.svg +182 -0
- package/templates/logo.txt +7 -0
- package/templates/opencode-plugin.ts +180 -0
- package/templates/pi-extension.ts +364 -0
- package/templates/skill/SKILL.md +189 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Runs of anything that is not an ASCII/Unicode letter or digit. This is a
|
|
2
|
+
// splitter, not a validator, so it stays permissive about what counts as a
|
|
3
|
+
// "word" — the length-3 filter below does the real filtering.
|
|
4
|
+
const WORD_BREAK = /[^\p{L}\p{N}]+/u;
|
|
5
|
+
/**
|
|
6
|
+
* Turn a frontmatter `tags:` value into search terms.
|
|
7
|
+
*
|
|
8
|
+
* Two terms by default: each store call costs a subprocess or a query, and
|
|
9
|
+
* two terms is the least that still lets matched-term-count ranking mean
|
|
10
|
+
* anything (a hit that matched both terms outranks one that matched one).
|
|
11
|
+
*/
|
|
12
|
+
export function tagsToTerms(value, maxTerms = 2) {
|
|
13
|
+
let tags;
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
tags = [value];
|
|
16
|
+
}
|
|
17
|
+
else if (Array.isArray(value)) {
|
|
18
|
+
tags = value.filter((v) => typeof v === 'string');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const seenLower = new Set();
|
|
24
|
+
const terms = [];
|
|
25
|
+
// One term per tag, taken BEFORE the maxTerms cap is applied. A single
|
|
26
|
+
// hyphenated tag splits into several words; if the cap were applied to
|
|
27
|
+
// that per-word list, one tag could exhaust the whole budget and starve
|
|
28
|
+
// every other authored tag (see the worked example above the cap).
|
|
29
|
+
// Picking one representative word per tag first means the cap trims
|
|
30
|
+
// across tags instead of within one.
|
|
31
|
+
for (const tag of tags) {
|
|
32
|
+
const words = tag.split(WORD_BREAK).filter((w) => w.length >= 3);
|
|
33
|
+
if (words.length === 0)
|
|
34
|
+
continue;
|
|
35
|
+
const word = words[0];
|
|
36
|
+
const lower = word.toLowerCase();
|
|
37
|
+
if (seenLower.has(lower))
|
|
38
|
+
continue;
|
|
39
|
+
seenLower.add(lower);
|
|
40
|
+
terms.push(word);
|
|
41
|
+
}
|
|
42
|
+
// NEVER sort. Tags are search terms the author chose and ordered; the shell
|
|
43
|
+
// implementation piped them through `sort -u` for the dedupe and the sort
|
|
44
|
+
// silently reordered them too, so cutting to maxTerms took the two
|
|
45
|
+
// alphabetically-first fragments instead of the two the author actually
|
|
46
|
+
// wrote first. `tags: [run-pin, carry, query-param]` searched "carry" and
|
|
47
|
+
// "param" and dropped the primary tag entirely. Authored order must survive
|
|
48
|
+
// to the slice below.
|
|
49
|
+
return terms.slice(0, maxTerms);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Stable sort by score, descending, then cap. Scores are only ever compared
|
|
53
|
+
* within one store's hits — a store that cannot attribute a match to
|
|
54
|
+
* individual terms and reports one flat score should never be capped
|
|
55
|
+
* alongside a store that scores per-term, so callers must call this once per
|
|
56
|
+
* store's hit list, not once over a merged list.
|
|
57
|
+
*/
|
|
58
|
+
export function capHits(hits, limit) {
|
|
59
|
+
// Array#sort is stable per spec (Node/V8 for years now), so ties keep
|
|
60
|
+
// their input order without any extra bookkeeping.
|
|
61
|
+
return [...hits].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a document stays in `attemptedEmpty` across prompts. Short and
|
|
3
|
+
* deliberately so: this set means "found nothing last time, worth another
|
|
4
|
+
* look once a store has something to say", not "never look again". A long
|
|
5
|
+
* or missing expiry would let a document that happened to catch the stores
|
|
6
|
+
* cold on prompt one stay unreachable for the rest of the session — the
|
|
7
|
+
* exact starvation bug the attempt-budget rotation exists to prevent.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ATTEMPTED_EMPTY_TTL_MS: number;
|
|
10
|
+
export type SessionMarkers = {
|
|
11
|
+
seen: Set<string>;
|
|
12
|
+
attemptedEmpty: Set<string>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A loaded session's markers plus enough state to save them back correctly.
|
|
16
|
+
* `federateTags` mutates `markers.seen` / `markers.attemptedEmpty` in place;
|
|
17
|
+
* pass the same handle to `saveSessionMarkers` afterward so an id that was
|
|
18
|
+
* already in `attemptedEmpty` before this call keeps its original recorded
|
|
19
|
+
* time instead of having its TTL silently refreshed on every prompt.
|
|
20
|
+
*/
|
|
21
|
+
export type SessionHandle = {
|
|
22
|
+
sessionId: string | undefined;
|
|
23
|
+
markers: SessionMarkers;
|
|
24
|
+
/** doc id -> original timestamp, for entries loaded from disk. */
|
|
25
|
+
attemptedEmptySince: Map<string, number>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Load persisted markers for a session. Returns a handle with fresh, empty
|
|
29
|
+
* Sets — never throws, never blocks the caller — whenever the marker path is
|
|
30
|
+
* missing, unreadable, unsafe, or corrupt. An empty result just means
|
|
31
|
+
* federation runs as if this were the first prompt of the session: dedupe is
|
|
32
|
+
* lost for this call, but the lookup itself still happens.
|
|
33
|
+
*
|
|
34
|
+
* No session id means no persistence: the caller gets fresh Sets and
|
|
35
|
+
* `saveSessionMarkers` becomes a no-op, never falling back to any shared or
|
|
36
|
+
* global file.
|
|
37
|
+
*/
|
|
38
|
+
export declare function loadSessionMarkers(sessionId: string | undefined): SessionHandle;
|
|
39
|
+
/**
|
|
40
|
+
* Persist a handle's markers after a `federateTags` call has mutated them.
|
|
41
|
+
* An id already present at load time keeps the timestamp it was loaded
|
|
42
|
+
* with, so its TTL counts from when it first came back empty, not from
|
|
43
|
+
* every subsequent prompt that re-skips it for free. An id that is new this
|
|
44
|
+
* call is stamped with the current time.
|
|
45
|
+
*/
|
|
46
|
+
export declare function saveSessionMarkers(handle: SessionHandle): void;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Session-scoped persistence for federation dedupe markers.
|
|
2
|
+
//
|
|
3
|
+
// `lat hook` runs as a fresh process per prompt, so the in-process `seen` and
|
|
4
|
+
// `attemptedEmpty` Sets that `federateTags` accepts start empty every time
|
|
5
|
+
// unless something persists them across invocations. This module is that
|
|
6
|
+
// something: a small on-disk marker store keyed on the hook payload's
|
|
7
|
+
// session id.
|
|
8
|
+
//
|
|
9
|
+
// The marker path is predictable from the session id (a hash of it), so
|
|
10
|
+
// another local user who can predict or observe the session id could plant a
|
|
11
|
+
// symlink there ahead of us. The checks below port the `private_dir` pattern
|
|
12
|
+
// from the shell hooks this ports from: verify no symlink, create with mode
|
|
13
|
+
// 0700, then re-verify ownership and mode AFTER creation (mkdir -p succeeds
|
|
14
|
+
// silently on a path that's already a symlink to a directory). Every open
|
|
15
|
+
// additionally passes O_NOFOLLOW so a link planted in the gap between our
|
|
16
|
+
// checks and the actual read/write still can't be followed.
|
|
17
|
+
//
|
|
18
|
+
// Failing any of this must never fail the prompt and must never suppress a
|
|
19
|
+
// lookup: on any doubt about the marker path's safety, we drop back to fresh,
|
|
20
|
+
// empty Sets. Federation still runs (with no cross-prompt memory for this
|
|
21
|
+
// call) rather than either crashing or letting a hostile file decide whether
|
|
22
|
+
// federation fires.
|
|
23
|
+
import { constants as fsConstants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
/**
|
|
28
|
+
* How long a document stays in `attemptedEmpty` across prompts. Short and
|
|
29
|
+
* deliberately so: this set means "found nothing last time, worth another
|
|
30
|
+
* look once a store has something to say", not "never look again". A long
|
|
31
|
+
* or missing expiry would let a document that happened to catch the stores
|
|
32
|
+
* cold on prompt one stay unreachable for the rest of the session — the
|
|
33
|
+
* exact starvation bug the attempt-budget rotation exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export const ATTEMPTED_EMPTY_TTL_MS = 5 * 60 * 1000;
|
|
36
|
+
function markerDir(sessionId) {
|
|
37
|
+
const hash = createHash('sha256')
|
|
38
|
+
.update(sessionId)
|
|
39
|
+
.digest('hex')
|
|
40
|
+
.slice(0, 32);
|
|
41
|
+
return join(tmpdir(), `lat-session-${hash}`);
|
|
42
|
+
}
|
|
43
|
+
function markerFile(sessionId) {
|
|
44
|
+
return join(markerDir(sessionId), 'markers.json');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Verify (and if needed, tighten) that `dir` is a private directory we own,
|
|
48
|
+
* creating it if absent. Returns false on anything that looks even slightly
|
|
49
|
+
* off — a symlink, a directory owned by someone else, permissions we can't
|
|
50
|
+
* correct — rather than trying to be clever about partial trust.
|
|
51
|
+
*/
|
|
52
|
+
function ensurePrivateDir(dir) {
|
|
53
|
+
try {
|
|
54
|
+
// Refuse a pre-existing symlink outright, before mkdir touches it.
|
|
55
|
+
try {
|
|
56
|
+
const preStat = lstatSync(dir);
|
|
57
|
+
if (preStat.isSymbolicLink())
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Doesn't exist yet — fine, mkdir below creates it.
|
|
62
|
+
}
|
|
63
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
64
|
+
// Re-check AFTER mkdir: `mkdir` with `recursive: true` succeeds silently
|
|
65
|
+
// on a path that is already a symlink to a directory, so the check above
|
|
66
|
+
// alone isn't enough — an attacker could win the race between it and the
|
|
67
|
+
// mkdir call.
|
|
68
|
+
const st = lstatSync(dir);
|
|
69
|
+
if (st.isSymbolicLink())
|
|
70
|
+
return false;
|
|
71
|
+
if (!st.isDirectory())
|
|
72
|
+
return false;
|
|
73
|
+
if (typeof st.uid === 'number' && typeof process.getuid === 'function') {
|
|
74
|
+
if (st.uid !== process.getuid())
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Mode must be exactly 0700: group/world access on a directory holding
|
|
78
|
+
// doc summaries and store hits is a leak, not just a corruption risk.
|
|
79
|
+
if ((st.mode & 0o777) !== 0o700)
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Open with O_NOFOLLOW so a symlink planted after ensurePrivateDir's checks
|
|
89
|
+
* still can't be followed for the actual read/write.
|
|
90
|
+
*/
|
|
91
|
+
function openNoFollow(path, flags, mode) {
|
|
92
|
+
try {
|
|
93
|
+
return openSync(path, flags, mode);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function emptyHandle(sessionId) {
|
|
100
|
+
return {
|
|
101
|
+
sessionId,
|
|
102
|
+
markers: { seen: new Set(), attemptedEmpty: new Set() },
|
|
103
|
+
attemptedEmptySince: new Map(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Load persisted markers for a session. Returns a handle with fresh, empty
|
|
108
|
+
* Sets — never throws, never blocks the caller — whenever the marker path is
|
|
109
|
+
* missing, unreadable, unsafe, or corrupt. An empty result just means
|
|
110
|
+
* federation runs as if this were the first prompt of the session: dedupe is
|
|
111
|
+
* lost for this call, but the lookup itself still happens.
|
|
112
|
+
*
|
|
113
|
+
* No session id means no persistence: the caller gets fresh Sets and
|
|
114
|
+
* `saveSessionMarkers` becomes a no-op, never falling back to any shared or
|
|
115
|
+
* global file.
|
|
116
|
+
*/
|
|
117
|
+
export function loadSessionMarkers(sessionId) {
|
|
118
|
+
// `sessionId` typically arrives via `JSON.parse` on untyped hook input, so
|
|
119
|
+
// a malformed or differently-shaped payload can hand this a non-string.
|
|
120
|
+
// Treat that exactly like "no session id" rather than letting it reach
|
|
121
|
+
// createHash() and throw past this function's callers.
|
|
122
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
123
|
+
return emptyHandle(undefined);
|
|
124
|
+
}
|
|
125
|
+
const dir = markerDir(sessionId);
|
|
126
|
+
if (!ensurePrivateDir(dir))
|
|
127
|
+
return emptyHandle(sessionId);
|
|
128
|
+
const file = markerFile(sessionId);
|
|
129
|
+
const fd = openNoFollow(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
130
|
+
if (fd === null)
|
|
131
|
+
return emptyHandle(sessionId);
|
|
132
|
+
try {
|
|
133
|
+
const st = fstatSync(fd);
|
|
134
|
+
if (!st.isFile())
|
|
135
|
+
return emptyHandle(sessionId);
|
|
136
|
+
const raw = readFileSync(fd, 'utf8');
|
|
137
|
+
const parsed = JSON.parse(raw);
|
|
138
|
+
const seen = new Set(Array.isArray(parsed.seen) ? parsed.seen : []);
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const attemptedEmpty = new Set();
|
|
141
|
+
const attemptedEmptySince = new Map();
|
|
142
|
+
if (parsed.attemptedEmpty && typeof parsed.attemptedEmpty === 'object') {
|
|
143
|
+
for (const [id, ts] of Object.entries(parsed.attemptedEmpty)) {
|
|
144
|
+
if (typeof ts === 'number' && now - ts < ATTEMPTED_EMPTY_TTL_MS) {
|
|
145
|
+
attemptedEmpty.add(id);
|
|
146
|
+
attemptedEmptySince.set(id, ts);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
sessionId,
|
|
152
|
+
markers: { seen, attemptedEmpty },
|
|
153
|
+
attemptedEmptySince,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return emptyHandle(sessionId);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Persist a handle's markers after a `federateTags` call has mutated them.
|
|
165
|
+
* An id already present at load time keeps the timestamp it was loaded
|
|
166
|
+
* with, so its TTL counts from when it first came back empty, not from
|
|
167
|
+
* every subsequent prompt that re-skips it for free. An id that is new this
|
|
168
|
+
* call is stamped with the current time.
|
|
169
|
+
*/
|
|
170
|
+
export function saveSessionMarkers(handle) {
|
|
171
|
+
const { sessionId, markers, attemptedEmptySince } = handle;
|
|
172
|
+
if (!sessionId)
|
|
173
|
+
return;
|
|
174
|
+
const dir = markerDir(sessionId);
|
|
175
|
+
if (!ensurePrivateDir(dir))
|
|
176
|
+
return;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const attemptedEmpty = {};
|
|
179
|
+
for (const id of markers.attemptedEmpty) {
|
|
180
|
+
attemptedEmpty[id] = attemptedEmptySince.get(id) ?? now;
|
|
181
|
+
}
|
|
182
|
+
const payload = {
|
|
183
|
+
seen: [...markers.seen],
|
|
184
|
+
attemptedEmpty,
|
|
185
|
+
};
|
|
186
|
+
const file = markerFile(sessionId);
|
|
187
|
+
const fd = openNoFollow(file, fsConstants.O_WRONLY |
|
|
188
|
+
fsConstants.O_CREAT |
|
|
189
|
+
fsConstants.O_TRUNC |
|
|
190
|
+
fsConstants.O_NOFOLLOW, 0o600);
|
|
191
|
+
if (fd === null)
|
|
192
|
+
return;
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(fd, JSON.stringify(payload));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Best-effort; a failed write just means this call's markers aren't
|
|
198
|
+
// persisted, same as if the session had no marker store at all.
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
closeSync(fd);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type StoreName = 'cq' | 'bd' | 'claude-memory';
|
|
2
|
+
/** One result from one store. Text is RAW here — the caller quotes it. */
|
|
3
|
+
export type KnowledgeHit = {
|
|
4
|
+
store: StoreName;
|
|
5
|
+
/**
|
|
6
|
+
* Stable identity, used for dedupe and for match counting: a bd memory key,
|
|
7
|
+
* an absolute memory-file path, a cq row identity. Never shown to a reader.
|
|
8
|
+
*/
|
|
9
|
+
key: string;
|
|
10
|
+
/** Short label shown first. A bd key, a memory `name:`, a cq summary. */
|
|
11
|
+
title: string;
|
|
12
|
+
/** Longer text shown after the title. May be empty. */
|
|
13
|
+
detail: string;
|
|
14
|
+
/**
|
|
15
|
+
* How many query terms matched this hit. Higher sorts first. A store that
|
|
16
|
+
* ranks internally (cq, by bm25) reports the number of terms it was given.
|
|
17
|
+
*/
|
|
18
|
+
score: number;
|
|
19
|
+
};
|
|
20
|
+
/** Everything a store needs for one lookup. */
|
|
21
|
+
export type StoreQuery = {
|
|
22
|
+
/**
|
|
23
|
+
* Search terms in the order the author wrote the tags. NEVER re-sort this
|
|
24
|
+
* array: the order is the author's ranking of the terms, and sorting makes
|
|
25
|
+
* the primary tag lose to an alphabetical accident.
|
|
26
|
+
*/
|
|
27
|
+
terms: string[];
|
|
28
|
+
/** Absolute path to the project root (the parent of lat.md/). */
|
|
29
|
+
projectRoot: string;
|
|
30
|
+
/** Most hits this store may return for this one query. */
|
|
31
|
+
limit: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A knowledge store. `query` resolves to [] when the store is unavailable for
|
|
35
|
+
* any reason. It must never throw and must never write to stdout or stderr.
|
|
36
|
+
*/
|
|
37
|
+
export type Store = {
|
|
38
|
+
name: StoreName;
|
|
39
|
+
query(q: StoreQuery): Promise<KnowledgeHit[]>;
|
|
40
|
+
};
|
|
41
|
+
/** A lat.md document that carries `lat: { tags: [...] }` in its frontmatter. */
|
|
42
|
+
export type TaggedDoc = {
|
|
43
|
+
/** Section or document id, used as the dedupe key and as the block heading. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** Tags exactly as authored, in the authored order. */
|
|
46
|
+
tags: string[];
|
|
47
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The contract every knowledge store implements. This file is the integration
|
|
2
|
+
// point between the three store modules and the federation entry point, so it
|
|
3
|
+
// is deliberately the only file in src/knowledge/ that none of them owns.
|
|
4
|
+
//
|
|
5
|
+
// The governing rule for the whole directory: a store is OPTIONAL. Absent
|
|
6
|
+
// binary, absent database, absent directory, malformed row, non-zero exit,
|
|
7
|
+
// timeout — every one of those is an empty result, never a thrown error and
|
|
8
|
+
// never a message on stderr. `lat hook` must not fail a user's prompt because
|
|
9
|
+
// a lookup that was never guaranteed to find anything found nothing.
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Root } from 'mdast';
|
|
2
|
+
export type Section = {
|
|
3
|
+
id: string;
|
|
4
|
+
heading: string;
|
|
5
|
+
depth: number;
|
|
6
|
+
file: string;
|
|
7
|
+
filePath: string;
|
|
8
|
+
children: Section[];
|
|
9
|
+
startLine: number;
|
|
10
|
+
endLine: number;
|
|
11
|
+
firstParagraph: string;
|
|
12
|
+
/** GitHub-compatible anchor generated from the rendered heading text. */
|
|
13
|
+
githubSlug?: string;
|
|
14
|
+
};
|
|
15
|
+
export type Ref = {
|
|
16
|
+
target: string;
|
|
17
|
+
fromSection: string;
|
|
18
|
+
file: string;
|
|
19
|
+
line: number;
|
|
20
|
+
};
|
|
21
|
+
/** An ordinary markdown destination or an undefined reference-style link. */
|
|
22
|
+
export type MdLink = {
|
|
23
|
+
/** Destination exactly as authored, percent-escapes and all. */
|
|
24
|
+
url: string;
|
|
25
|
+
kind: 'link' | 'image' | 'definition';
|
|
26
|
+
line: number;
|
|
27
|
+
} | {
|
|
28
|
+
/** Authored label that should have a matching definition. */
|
|
29
|
+
identifier: string;
|
|
30
|
+
/** Full reference syntax exactly as authored. */
|
|
31
|
+
source: string;
|
|
32
|
+
kind: 'linkReference' | 'imageReference';
|
|
33
|
+
line: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Every field this build reads out of the `lat:` mapping. Kept in one place so
|
|
37
|
+
* a field added later is covered by the misplacement check without a second
|
|
38
|
+
* edit — the trap this list guards against is silent, so it must not depend on
|
|
39
|
+
* whoever adds the next field remembering to register it twice.
|
|
40
|
+
*/
|
|
41
|
+
export declare const LAT_FIELDS: readonly ["require-code-mention", "mode", "status", "reviewed-hash", "tags"];
|
|
42
|
+
/**
|
|
43
|
+
* Something wrong with the frontmatter itself, carried out of the parser for a
|
|
44
|
+
* caller to report. Both kinds fail OPEN if they stay quiet: a misplaced or
|
|
45
|
+
* unparseable `require-code-mention` turns a validation off and `lat check`
|
|
46
|
+
* then reports success on a file that is no longer being validated.
|
|
47
|
+
*/
|
|
48
|
+
export type FrontmatterProblem = {
|
|
49
|
+
kind: 'root-level-field';
|
|
50
|
+
field: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'parse-error';
|
|
53
|
+
message: string;
|
|
54
|
+
};
|
|
55
|
+
export type LatFrontmatter = {
|
|
56
|
+
/** Every key under the `lat:` mapping, parsed but not mapped to a field. */
|
|
57
|
+
raw: Record<string, unknown>;
|
|
58
|
+
requireCodeMention?: boolean;
|
|
59
|
+
/** Populated only when something is wrong; absent on a healthy document. */
|
|
60
|
+
problems?: FrontmatterProblem[];
|
|
61
|
+
};
|
|
62
|
+
export declare function parseFrontmatter(content: string): LatFrontmatter;
|
|
63
|
+
export declare function findLatticeDir(from?: string): string | null;
|
|
64
|
+
export declare function findProjectRoot(from?: string): string | null;
|
|
65
|
+
export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
|
|
66
|
+
export declare function parseSections(filePath: string, content: string, projectRoot?: string, tree?: Root): Section[];
|
|
67
|
+
export declare function loadAllSections(latticeDir: string, projectRoot?: string): Promise<Section[]>;
|
|
68
|
+
export declare function flattenSections(sections: Section[]): Section[];
|
|
69
|
+
/**
|
|
70
|
+
* Build an index mapping path suffixes to their full vault-relative paths.
|
|
71
|
+
* Used by resolveRef to allow short references when a suffix is unambiguous.
|
|
72
|
+
*
|
|
73
|
+
* For a file like `lat.md/guides/setup`, indexes both `guides/setup` and `setup`.
|
|
74
|
+
* This ensures backward-compatible short refs after the vault root moved to the
|
|
75
|
+
* project root (so section IDs now include the `lat.md/` prefix).
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildFileIndex(sections: Section[]): Map<string, string[]>;
|
|
78
|
+
export type SectionSlugIndex = ReadonlyMap<string, string>;
|
|
79
|
+
/**
|
|
80
|
+
* Map GitHub-slugged heading paths back to canonical, literal-heading ids.
|
|
81
|
+
* Every path segment accepts either representation so an implicit literal h1
|
|
82
|
+
* can still prefix slugged child headings during short-ref resolution.
|
|
83
|
+
*/
|
|
84
|
+
export declare function buildSectionSlugIndex(sections: Section[]): Map<string, string>;
|
|
85
|
+
export type ResolveResult = {
|
|
86
|
+
resolved: string;
|
|
87
|
+
ambiguous: string[] | null;
|
|
88
|
+
/** When ambiguous but exactly one candidate has the section, suggest it. */
|
|
89
|
+
suggested: string | null;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a potentially short reference to its canonical full-path form.
|
|
93
|
+
* If the file segment of the ref is a bare stem that uniquely maps to one
|
|
94
|
+
* full path, expands it. Otherwise returns the ref unchanged.
|
|
95
|
+
*
|
|
96
|
+
* When ambiguous (multiple files share the stem), returns all candidates.
|
|
97
|
+
* If exactly one candidate actually contains the referenced section,
|
|
98
|
+
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
99
|
+
*/
|
|
100
|
+
export declare function resolveRef(target: string, sectionIds: Set<string>, fileIndex: Map<string, string[]>, slugIndex?: SectionSlugIndex): ResolveResult;
|
|
101
|
+
export type SectionMatch = {
|
|
102
|
+
section: Section;
|
|
103
|
+
reason: string;
|
|
104
|
+
};
|
|
105
|
+
export declare function findSections(sections: Section[], query: string): SectionMatch[];
|
|
106
|
+
export declare function extractRefs(filePath: string, content: string, projectRoot?: string, tree?: Root): Ref[];
|
|
107
|
+
/** Extract link destinations and undefined full/collapsed references. */
|
|
108
|
+
export declare function extractLinks(content: string, tree?: Root): MdLink[];
|