@aiwayds/dsh-topics-memory 0.6.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/README.md +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/lib/paths.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution for the local bundle (ADR 0008): the Cache lives at
|
|
3
|
+
* `~/.dsh/topics/` (override with $DSH_TOPICS_HOME for tests), laid out as
|
|
4
|
+
* an OKF bundle:
|
|
5
|
+
*
|
|
6
|
+
* <root>/topics/<slug>.md concept documents
|
|
7
|
+
* <root>/index.md auto-generated bundle index
|
|
8
|
+
* <root>/meta/observations.jsonl undistilled observations (M2)
|
|
9
|
+
* <root>/meta/injections.jsonl injection log (ADR 0007)
|
|
10
|
+
* <root>/meta/conflicts.json rebase-conflicted topics (ADR 0003)
|
|
11
|
+
*
|
|
12
|
+
* @module paths
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveDshHome(): string;
|
|
15
|
+
export declare function resolveBundleRoot(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Minimal fs surface, injectable so tests can hit the rename-race catch
|
|
18
|
+
* deterministically (a real-fs test cannot mutate the world between the
|
|
19
|
+
* guard checks and the rename). Defaults to the real node:fs functions —
|
|
20
|
+
* production callers never pass it.
|
|
21
|
+
*/
|
|
22
|
+
interface FsLike {
|
|
23
|
+
existsSync(path: string): boolean;
|
|
24
|
+
renameSync(from: string, to: string): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One-time migration from the pre-rename data location (`~/.dsh/llmwiki` to
|
|
28
|
+
* `~/.dsh/topics`, ADR 0013). Constraints:
|
|
29
|
+
* - one-time: fires only while the new directory is absent and the legacy one
|
|
30
|
+
* exists; after the rename (or a fresh bundle on the new path) the check is
|
|
31
|
+
* a no-op;
|
|
32
|
+
* - fail-open: any rename error falls back to the legacy path so the plugin
|
|
33
|
+
* keeps serving the old bundle instead of booting an empty new one;
|
|
34
|
+
* - bypassed entirely by an explicit $DSH_TOPICS_HOME (test/CI isolation).
|
|
35
|
+
*/
|
|
36
|
+
export declare function migrateLegacyBundleRoot(next: string, legacy: string, fs?: FsLike): string;
|
|
37
|
+
export declare function topicsDir(root: string): string;
|
|
38
|
+
export declare function metaDir(root: string): string;
|
|
39
|
+
export declare function topicFile(root: string, slug: string): string;
|
|
40
|
+
export declare function indexFile(root: string): string;
|
|
41
|
+
export declare function observationsFile(root: string): string;
|
|
42
|
+
export declare function injectionsFile(root: string): string;
|
|
43
|
+
export declare function conflictsFile(root: string): string;
|
|
44
|
+
/** Sanitized host name recorded in `generated.by` for multi-machine provenance. */
|
|
45
|
+
export declare function hostId(): string;
|
|
46
|
+
/** The actor string stamped into `generated.by` for machine-written topics. */
|
|
47
|
+
export declare function actorFor(): string;
|
|
48
|
+
export {};
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution for the local bundle (ADR 0008): the Cache lives at
|
|
3
|
+
* `~/.dsh/topics/` (override with $DSH_TOPICS_HOME for tests), laid out as
|
|
4
|
+
* an OKF bundle:
|
|
5
|
+
*
|
|
6
|
+
* <root>/topics/<slug>.md concept documents
|
|
7
|
+
* <root>/index.md auto-generated bundle index
|
|
8
|
+
* <root>/meta/observations.jsonl undistilled observations (M2)
|
|
9
|
+
* <root>/meta/injections.jsonl injection log (ADR 0007)
|
|
10
|
+
* <root>/meta/conflicts.json rebase-conflicted topics (ADR 0003)
|
|
11
|
+
*
|
|
12
|
+
* @module paths
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, renameSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { hostname as osHostname } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
export function resolveDshHome() {
|
|
19
|
+
const env = process.env.DSH_HOME?.trim();
|
|
20
|
+
if (env !== undefined && env !== '')
|
|
21
|
+
return env;
|
|
22
|
+
return join(homedir(), '.dsh');
|
|
23
|
+
}
|
|
24
|
+
export function resolveBundleRoot() {
|
|
25
|
+
const env = process.env.DSH_TOPICS_HOME?.trim();
|
|
26
|
+
if (env !== undefined && env !== '')
|
|
27
|
+
return env;
|
|
28
|
+
const dshHome = resolveDshHome();
|
|
29
|
+
return migrateLegacyBundleRoot(join(dshHome, 'topics'), join(dshHome, 'llmwiki'));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One-time migration from the pre-rename data location (`~/.dsh/llmwiki` to
|
|
33
|
+
* `~/.dsh/topics`, ADR 0013). Constraints:
|
|
34
|
+
* - one-time: fires only while the new directory is absent and the legacy one
|
|
35
|
+
* exists; after the rename (or a fresh bundle on the new path) the check is
|
|
36
|
+
* a no-op;
|
|
37
|
+
* - fail-open: any rename error falls back to the legacy path so the plugin
|
|
38
|
+
* keeps serving the old bundle instead of booting an empty new one;
|
|
39
|
+
* - bypassed entirely by an explicit $DSH_TOPICS_HOME (test/CI isolation).
|
|
40
|
+
*/
|
|
41
|
+
export function migrateLegacyBundleRoot(next, legacy, fs = { existsSync, renameSync }) {
|
|
42
|
+
if (fs.existsSync(next) || !fs.existsSync(legacy))
|
|
43
|
+
return next;
|
|
44
|
+
try {
|
|
45
|
+
fs.renameSync(legacy, next);
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A concurrent boot may have won the rename: re-check the live filesystem
|
|
50
|
+
// instead of trusting the stale pre-rename view — falling back to `legacy`
|
|
51
|
+
// after it was moved away would strand this session on a dead path (empty
|
|
52
|
+
// bundle, writes into an orphan directory nothing will ever read).
|
|
53
|
+
return fs.existsSync(next) ? next : legacy;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function topicsDir(root) {
|
|
57
|
+
return join(root, 'topics');
|
|
58
|
+
}
|
|
59
|
+
export function metaDir(root) {
|
|
60
|
+
return join(root, 'meta');
|
|
61
|
+
}
|
|
62
|
+
export function topicFile(root, slug) {
|
|
63
|
+
return join(root, 'topics', `${slug}.md`);
|
|
64
|
+
}
|
|
65
|
+
export function indexFile(root) {
|
|
66
|
+
return join(root, 'index.md');
|
|
67
|
+
}
|
|
68
|
+
export function observationsFile(root) {
|
|
69
|
+
return join(metaDir(root), 'observations.jsonl');
|
|
70
|
+
}
|
|
71
|
+
export function injectionsFile(root) {
|
|
72
|
+
return join(metaDir(root), 'injections.jsonl');
|
|
73
|
+
}
|
|
74
|
+
export function conflictsFile(root) {
|
|
75
|
+
return join(metaDir(root), 'conflicts.json');
|
|
76
|
+
}
|
|
77
|
+
/** Sanitized host name recorded in `generated.by` for multi-machine provenance. */
|
|
78
|
+
export function hostId() {
|
|
79
|
+
return osHostname().replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '') || 'unknown-host';
|
|
80
|
+
}
|
|
81
|
+
/** The actor string stamped into `generated.by` for machine-written topics. */
|
|
82
|
+
export function actorFor() {
|
|
83
|
+
return `agent:dsh-topics-memory@${hostId()}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-path retrieval — LLM-free, millisecond-scale (ADR 0004).
|
|
3
|
+
*
|
|
4
|
+
* Lineage: pi-topic-memory's tokenizer + Dice matching, adapted to the Topic
|
|
5
|
+
* profile. Query tokens are matched against title/tags/slug/description/
|
|
6
|
+
* conclusion via containment (|query ∩ field| / |query|), so long topics are
|
|
7
|
+
* not penalized the way plain Dice dilutes them. Tag hits add a bounded
|
|
8
|
+
* boost, recent topics a small recency bonus, and the `depends` graph expands
|
|
9
|
+
* seeds to adjacent topics with geometric decay. Conflicted topics are
|
|
10
|
+
* demoted, never dropped (ADR 0003).
|
|
11
|
+
*
|
|
12
|
+
* @module retrieval
|
|
13
|
+
*/
|
|
14
|
+
import type { TopicMeta } from './store.ts';
|
|
15
|
+
export interface RetrievableTopic extends TopicMeta {
|
|
16
|
+
/** `# Conclusion` section text (may be empty). */
|
|
17
|
+
conclusion: string;
|
|
18
|
+
/** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
|
|
19
|
+
links?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface RetrievalConfig {
|
|
22
|
+
threshold: number;
|
|
23
|
+
topK: number;
|
|
24
|
+
tagBoost: number;
|
|
25
|
+
graphDepth: number;
|
|
26
|
+
recencyWindowDays: number;
|
|
27
|
+
now?: Date;
|
|
28
|
+
/** Slugs currently conflicted — demoted, not dropped. */
|
|
29
|
+
conflicts?: ReadonlySet<string>;
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_RETRIEVAL: RetrievalConfig;
|
|
32
|
+
export interface SearchHit {
|
|
33
|
+
slug: string;
|
|
34
|
+
score: number;
|
|
35
|
+
reasons: string[];
|
|
36
|
+
viaGraph: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface SearchOutcome {
|
|
39
|
+
hits: SearchHit[];
|
|
40
|
+
/** Below threshold but close — the tuning evidence for /topics stats (ADR 0007). */
|
|
41
|
+
nearMisses: SearchHit[];
|
|
42
|
+
rosterSize: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Tokenize for matching: latin/digit words (≥2 chars) plus CJK character
|
|
46
|
+
* bigrams (single char kept when the run has length 1).
|
|
47
|
+
*/
|
|
48
|
+
export declare function tokenize(text: string): string[];
|
|
49
|
+
export interface ScoredTopic {
|
|
50
|
+
slug: string;
|
|
51
|
+
score: number;
|
|
52
|
+
reasons: string[];
|
|
53
|
+
}
|
|
54
|
+
/** Score one topic against the query tokens. */
|
|
55
|
+
export declare function scoreTopic(queryTokens: ReadonlySet<string>, topic: RetrievableTopic, cfg: RetrievalConfig): ScoredTopic;
|
|
56
|
+
/**
|
|
57
|
+
* Search the roster: score every topic, split hits from near-misses, then
|
|
58
|
+
* expand seeds through the `depends` graph (both directions, geometric decay,
|
|
59
|
+
* depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
|
|
60
|
+
*/
|
|
61
|
+
export declare function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg?: Partial<RetrievalConfig>): SearchOutcome;
|
package/lib/retrieval.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-path retrieval — LLM-free, millisecond-scale (ADR 0004).
|
|
3
|
+
*
|
|
4
|
+
* Lineage: pi-topic-memory's tokenizer + Dice matching, adapted to the Topic
|
|
5
|
+
* profile. Query tokens are matched against title/tags/slug/description/
|
|
6
|
+
* conclusion via containment (|query ∩ field| / |query|), so long topics are
|
|
7
|
+
* not penalized the way plain Dice dilutes them. Tag hits add a bounded
|
|
8
|
+
* boost, recent topics a small recency bonus, and the `depends` graph expands
|
|
9
|
+
* seeds to adjacent topics with geometric decay. Conflicted topics are
|
|
10
|
+
* demoted, never dropped (ADR 0003).
|
|
11
|
+
*
|
|
12
|
+
* @module retrieval
|
|
13
|
+
*/
|
|
14
|
+
import { dependsSlugs } from "./okf.js";
|
|
15
|
+
export const DEFAULT_RETRIEVAL = {
|
|
16
|
+
threshold: 0.3,
|
|
17
|
+
topK: 4,
|
|
18
|
+
tagBoost: 0.15,
|
|
19
|
+
graphDepth: 2,
|
|
20
|
+
recencyWindowDays: 7,
|
|
21
|
+
};
|
|
22
|
+
const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu;
|
|
23
|
+
const WORD_RUN = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}][\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}+#.-]*/gu;
|
|
24
|
+
/**
|
|
25
|
+
* Tokenize for matching: latin/digit words (≥2 chars) plus CJK character
|
|
26
|
+
* bigrams (single char kept when the run has length 1).
|
|
27
|
+
*/
|
|
28
|
+
export function tokenize(text) {
|
|
29
|
+
const out = new Set();
|
|
30
|
+
for (const w of text.toLowerCase().match(WORD_RUN) ?? []) {
|
|
31
|
+
if (w.length >= 2)
|
|
32
|
+
out.add(w);
|
|
33
|
+
}
|
|
34
|
+
for (const run of text.match(CJK_RUN) ?? []) {
|
|
35
|
+
if (run.length === 1) {
|
|
36
|
+
out.add(run);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < run.length - 1; i += 1)
|
|
40
|
+
out.add(run.slice(i, i + 2));
|
|
41
|
+
}
|
|
42
|
+
return [...out];
|
|
43
|
+
}
|
|
44
|
+
function containment(query, field) {
|
|
45
|
+
if (query.size === 0 || field.size === 0)
|
|
46
|
+
return 0;
|
|
47
|
+
let hits = 0;
|
|
48
|
+
for (const t of query)
|
|
49
|
+
if (field.has(t))
|
|
50
|
+
hits += 1;
|
|
51
|
+
return hits / query.size;
|
|
52
|
+
}
|
|
53
|
+
function overlap(a, b) {
|
|
54
|
+
for (const t of a)
|
|
55
|
+
if (b.has(t))
|
|
56
|
+
return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Score one topic against the query tokens. */
|
|
60
|
+
export function scoreTopic(queryTokens, topic, cfg) {
|
|
61
|
+
const title = new Set(tokenize(topic.title));
|
|
62
|
+
const slug = new Set(tokenize(topic.slug));
|
|
63
|
+
const tags = new Set(topic.tags.flatMap((t) => tokenize(t)));
|
|
64
|
+
const description = new Set(tokenize(topic.description ?? ''));
|
|
65
|
+
const conclusion = new Set(tokenize(topic.conclusion));
|
|
66
|
+
const reasons = [];
|
|
67
|
+
let score = 0;
|
|
68
|
+
const cTitle = containment(queryTokens, title);
|
|
69
|
+
if (cTitle > 0) {
|
|
70
|
+
score += 3 * cTitle;
|
|
71
|
+
reasons.push(`title:${cTitle.toFixed(2)}`);
|
|
72
|
+
}
|
|
73
|
+
const cTag = containment(queryTokens, new Set([...tags, ...slug]));
|
|
74
|
+
if (cTag > 0) {
|
|
75
|
+
score += 2 * cTag;
|
|
76
|
+
reasons.push(`tags:${cTag.toFixed(2)}`);
|
|
77
|
+
}
|
|
78
|
+
const cDesc = containment(queryTokens, description);
|
|
79
|
+
if (cDesc > 0) {
|
|
80
|
+
score += 1.2 * cDesc;
|
|
81
|
+
reasons.push(`description:${cDesc.toFixed(2)}`);
|
|
82
|
+
}
|
|
83
|
+
const cConc = containment(queryTokens, conclusion);
|
|
84
|
+
if (cConc > 0) {
|
|
85
|
+
score += 0.8 * cConc;
|
|
86
|
+
reasons.push(`conclusion:${cConc.toFixed(2)}`);
|
|
87
|
+
}
|
|
88
|
+
// Exact tag hit boost, additive and capped (pi-topic-memory lesson: add,
|
|
89
|
+
// never fold into the token set — dilution breaks the guard).
|
|
90
|
+
let tagHits = 0;
|
|
91
|
+
for (const t of tags) {
|
|
92
|
+
if (overlap(queryTokens, new Set([...tokenize(t)])))
|
|
93
|
+
tagHits += 1;
|
|
94
|
+
}
|
|
95
|
+
if (tagHits > 0) {
|
|
96
|
+
const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost * 3);
|
|
97
|
+
score += boost;
|
|
98
|
+
reasons.push(`tag-boost:+${boost.toFixed(2)}`);
|
|
99
|
+
}
|
|
100
|
+
// Recency is a tiebreaker among relevant topics, never a free pass: only
|
|
101
|
+
// fires when the query already matched some content above.
|
|
102
|
+
if (score > 0 && cfg.recencyWindowDays > 0) {
|
|
103
|
+
const generated = Date.parse(topic.generatedAt);
|
|
104
|
+
if (!Number.isNaN(generated)) {
|
|
105
|
+
const ageDays = ((cfg.now?.getTime() ?? Date.now()) - generated) / 86_400_000;
|
|
106
|
+
if (ageDays >= 0 && ageDays <= cfg.recencyWindowDays) {
|
|
107
|
+
score += 0.2;
|
|
108
|
+
reasons.push('recency');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (cfg.conflicts?.has(topic.slug) === true) {
|
|
113
|
+
score *= 0.3;
|
|
114
|
+
reasons.push('conflicted-demoted');
|
|
115
|
+
}
|
|
116
|
+
return { slug: topic.slug, score: Math.round(score * 1000) / 1000, reasons };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Search the roster: score every topic, split hits from near-misses, then
|
|
120
|
+
* expand seeds through the `depends` graph (both directions, geometric decay,
|
|
121
|
+
* depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
|
|
122
|
+
*/
|
|
123
|
+
export function searchTopics(query, roster, cfg = {}) {
|
|
124
|
+
const c = { ...DEFAULT_RETRIEVAL, ...cfg };
|
|
125
|
+
const queryTokens = new Set(tokenize(query));
|
|
126
|
+
if (queryTokens.size === 0 || roster.length === 0) {
|
|
127
|
+
return { hits: [], nearMisses: [], rosterSize: roster.length };
|
|
128
|
+
}
|
|
129
|
+
const scored = roster.map((t) => scoreTopic(queryTokens, t, c)).sort((a, b) => b.score - a.score);
|
|
130
|
+
const direct = scored.filter((s) => s.score >= c.threshold).slice(0, c.topK);
|
|
131
|
+
const near = scored.filter((s) => s.score < c.threshold && s.score >= c.threshold * 0.5).slice(0, 8);
|
|
132
|
+
const hits = direct.map((s) => ({ ...s, viaGraph: false }));
|
|
133
|
+
// Graph expansion (ADR 0005 graph-shaped bundle): walk depends + body
|
|
134
|
+
// links, both directions, from the seeds; include up to topK+2 slots at
|
|
135
|
+
// decaying score.
|
|
136
|
+
if (c.graphDepth > 0 && hits.length > 0) {
|
|
137
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]));
|
|
138
|
+
const neighborsOf = (slug) => {
|
|
139
|
+
const topic = bySlug.get(slug);
|
|
140
|
+
if (topic === undefined)
|
|
141
|
+
return [];
|
|
142
|
+
const out = new Set(dependsSlugs({ depends: topic.depends }));
|
|
143
|
+
for (const l of topic.links ?? [])
|
|
144
|
+
out.add(l);
|
|
145
|
+
return [...out];
|
|
146
|
+
};
|
|
147
|
+
const dependents = new Map();
|
|
148
|
+
for (const r of roster) {
|
|
149
|
+
for (const dep of new Set([...dependsSlugs({ depends: r.depends }), ...(r.links ?? [])])) {
|
|
150
|
+
const list = dependents.get(dep) ?? [];
|
|
151
|
+
list.push(r.slug);
|
|
152
|
+
dependents.set(dep, list);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const seen = new Set(hits.map((h) => h.slug));
|
|
156
|
+
let frontier = hits.map((h) => ({ slug: h.slug, score: h.score }));
|
|
157
|
+
for (let depth = 1; depth <= c.graphDepth && frontier.length > 0; depth += 1) {
|
|
158
|
+
const next = [];
|
|
159
|
+
for (const node of frontier) {
|
|
160
|
+
const neighbors = new Set([...neighborsOf(node.slug), ...(dependents.get(node.slug) ?? [])]);
|
|
161
|
+
for (const neighbor of neighbors) {
|
|
162
|
+
if (seen.has(neighbor))
|
|
163
|
+
continue;
|
|
164
|
+
seen.add(neighbor);
|
|
165
|
+
const decayed = node.score * 0.5 ** depth;
|
|
166
|
+
if (decayed >= c.threshold && hits.length < c.topK + 2) {
|
|
167
|
+
const already = hits.find((h) => h.slug === neighbor);
|
|
168
|
+
if (already === undefined)
|
|
169
|
+
hits.push({ slug: neighbor, score: Math.round(decayed * 1000) / 1000, reasons: [`depends:d${depth}`], viaGraph: true });
|
|
170
|
+
next.push({ slug: neighbor, score: decayed });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
frontier = next;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { hits, nearMisses: near.map((s) => ({ ...s, viaGraph: false })), rosterSize: roster.length };
|
|
178
|
+
}
|
package/lib/service.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicsService — the facade shared by tools, commands, the injection seam,
|
|
3
|
+
* and the distill lane. Owns the roster cache (mtime-keyed, pi-topic-memory
|
|
4
|
+
* lesson #3: never re-parse unchanged files on the hot path).
|
|
5
|
+
*
|
|
6
|
+
* @module service
|
|
7
|
+
*/
|
|
8
|
+
import * as okf from './okf.ts';
|
|
9
|
+
import { type RetrievableTopic, type SearchOutcome } from './retrieval.ts';
|
|
10
|
+
import { type AssembleResult } from './digest.ts';
|
|
11
|
+
import type { BundleStore, Observation, SaveResult } from './store.ts';
|
|
12
|
+
import type { TopicsConfigValue } from './config.ts';
|
|
13
|
+
import { type AggregateStats } from './ilog.ts';
|
|
14
|
+
import type { Sync } from './sync.ts';
|
|
15
|
+
export interface RetrieveOutcome {
|
|
16
|
+
outcome: SearchOutcome;
|
|
17
|
+
injection: AssembleResult;
|
|
18
|
+
/** Roster was empty or injection disabled — no record written for pure no-ops. */
|
|
19
|
+
recorded: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare class TopicsService {
|
|
22
|
+
private cache;
|
|
23
|
+
readonly store: BundleStore;
|
|
24
|
+
private readonly getConfig;
|
|
25
|
+
readonly sync?: Sync;
|
|
26
|
+
constructor(store: BundleStore, getConfig: () => TopicsConfigValue, sync?: Sync);
|
|
27
|
+
get cfg(): TopicsConfigValue;
|
|
28
|
+
get githubMode(): boolean;
|
|
29
|
+
/** All retrievable topics with conclusion text, mtime-cached. */
|
|
30
|
+
roster(): Promise<RetrievableTopic[]>;
|
|
31
|
+
/** Drop the roster cache (tests, bulk external edits). */
|
|
32
|
+
invalidate(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
35
|
+
* injection record is written even for zero-hit rounds — the hit-rate
|
|
36
|
+
* denominator must count every round (near-miss evidence).
|
|
37
|
+
*/
|
|
38
|
+
retrieve(query: string, sessionId?: string): Promise<RetrieveOutcome>;
|
|
39
|
+
/**
|
|
40
|
+
* SYNCHRONOUS hot path for same-turn injection (the chancelu lesson):
|
|
41
|
+
* `agent/inbox/spliced` dispatches before prompt assembly and the context
|
|
42
|
+
* provider reads the assembled text synchronously — an async retrieval
|
|
43
|
+
* would always lose the race and inject nothing. Reads files with sync fs
|
|
44
|
+
* behind the mtime cache; the log record is written fire-and-forget.
|
|
45
|
+
*
|
|
46
|
+
* `dedup.exclude` (session-level injection dedup) filters hits AFTER
|
|
47
|
+
* retrieval and BEFORE assembly: excluded slugs are reported back as
|
|
48
|
+
* `deduped` (and logged as `record.deduped`), never packed. `included`
|
|
49
|
+
* mirrors what actually entered the context — only these may be marked
|
|
50
|
+
* as injected by the caller; budget-dropped slugs stay injectable later.
|
|
51
|
+
*/
|
|
52
|
+
retrieveSync(query: string, sessionId?: string, dedup?: {
|
|
53
|
+
exclude?: ReadonlySet<string>;
|
|
54
|
+
}): {
|
|
55
|
+
text: string;
|
|
56
|
+
outcome: SearchOutcome;
|
|
57
|
+
deduped: string[];
|
|
58
|
+
included: string[];
|
|
59
|
+
};
|
|
60
|
+
/** Sync roster read (same mtime cache as roster()). */
|
|
61
|
+
rosterSync(): RetrievableTopic[];
|
|
62
|
+
private readDocSync;
|
|
63
|
+
private readDoc;
|
|
64
|
+
/** topic_save tool path: create or update with actor stamping. */
|
|
65
|
+
saveTopic(input: {
|
|
66
|
+
title: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
tags?: string[];
|
|
69
|
+
depends?: string[];
|
|
70
|
+
openQuestions?: string[];
|
|
71
|
+
impact?: string[];
|
|
72
|
+
status?: okf.TopicStatus;
|
|
73
|
+
conclusion?: string;
|
|
74
|
+
recommendations?: string;
|
|
75
|
+
source?: 'model' | 'distill';
|
|
76
|
+
slug?: string;
|
|
77
|
+
}): Promise<SaveResult & {
|
|
78
|
+
slug: string;
|
|
79
|
+
}>;
|
|
80
|
+
observe(input: {
|
|
81
|
+
kind: Observation['kind'];
|
|
82
|
+
text: string;
|
|
83
|
+
sessionId?: string;
|
|
84
|
+
source?: 'model' | 'auto';
|
|
85
|
+
}): Promise<Observation>;
|
|
86
|
+
search(query: string, topK?: number): Promise<SearchOutcome>;
|
|
87
|
+
history(slug: string, limit?: number): Promise<{
|
|
88
|
+
entries: {
|
|
89
|
+
hash: string;
|
|
90
|
+
date: string;
|
|
91
|
+
message: string;
|
|
92
|
+
conclusion?: string;
|
|
93
|
+
}[];
|
|
94
|
+
}>;
|
|
95
|
+
stats(): Promise<AggregateStats>;
|
|
96
|
+
}
|