@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.
Files changed (61) hide show
  1. package/README.md +112 -0
  2. package/README.zh.md +112 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/commands.d.ts +21 -0
  5. package/lib/commands.js +368 -0
  6. package/lib/config.d.ts +122 -0
  7. package/lib/config.js +123 -0
  8. package/lib/delegation.d.ts +23 -0
  9. package/lib/delegation.js +21 -0
  10. package/lib/digest.d.ts +42 -0
  11. package/lib/digest.js +101 -0
  12. package/lib/distill.d.ts +186 -0
  13. package/lib/distill.js +759 -0
  14. package/lib/git.d.ts +66 -0
  15. package/lib/git.js +169 -0
  16. package/lib/ilog.d.ts +53 -0
  17. package/lib/ilog.js +70 -0
  18. package/lib/index.d.ts +73 -0
  19. package/lib/index.js +504 -0
  20. package/lib/observer.d.ts +41 -0
  21. package/lib/observer.js +128 -0
  22. package/lib/okf.d.ts +112 -0
  23. package/lib/okf.js +279 -0
  24. package/lib/onboard.d.ts +205 -0
  25. package/lib/onboard.js +664 -0
  26. package/lib/paths.d.ts +48 -0
  27. package/lib/paths.js +84 -0
  28. package/lib/retrieval.d.ts +61 -0
  29. package/lib/retrieval.js +178 -0
  30. package/lib/service.d.ts +96 -0
  31. package/lib/service.js +375 -0
  32. package/lib/store.d.ts +153 -0
  33. package/lib/store.js +489 -0
  34. package/lib/sync.d.ts +52 -0
  35. package/lib/sync.js +154 -0
  36. package/lib/tools.d.ts +8 -0
  37. package/lib/tools.js +208 -0
  38. package/lib/viz.d.ts +33 -0
  39. package/lib/viz.js +310 -0
  40. package/lib/yaml.d.ts +18 -0
  41. package/lib/yaml.js +334 -0
  42. package/package.json +97 -0
  43. package/src/commands.ts +397 -0
  44. package/src/config.ts +149 -0
  45. package/src/delegation.ts +27 -0
  46. package/src/digest.ts +125 -0
  47. package/src/distill.ts +885 -0
  48. package/src/git.ts +209 -0
  49. package/src/ilog.ts +99 -0
  50. package/src/index.ts +574 -0
  51. package/src/observer.ts +150 -0
  52. package/src/okf.ts +317 -0
  53. package/src/onboard.ts +762 -0
  54. package/src/paths.ts +104 -0
  55. package/src/retrieval.ts +219 -0
  56. package/src/service.ts +400 -0
  57. package/src/store.ts +567 -0
  58. package/src/sync.ts +170 -0
  59. package/src/tools.ts +225 -0
  60. package/src/viz.ts +333 -0
  61. package/src/yaml.ts +322 -0
package/src/git.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Git CLI wrapper for the bundle Cache (ADR 0002/0003).
3
+ *
4
+ * Every command is an argv-array child process (no shell), with a timeout and
5
+ * a plugin identity for commits (`user.name`/`user.email` via -c, never
6
+ * touching the user's git config). In GitHub mode the token is attached
7
+ * per-invocation as an http.extraHeader — it never lands in the remote URL,
8
+ * .git/config, or any file (ADR 0002).
9
+ *
10
+ * @module git
11
+ */
12
+
13
+ import { spawn } from 'node:child_process'
14
+
15
+ export interface GitResult {
16
+ code: number
17
+ stdout: string
18
+ stderr: string
19
+ }
20
+
21
+ export class GitError extends Error {
22
+ override name = 'GitError'
23
+ readonly result: GitResult
24
+ constructor(message: string, result: GitResult) {
25
+ super(message)
26
+ this.result = result
27
+ }
28
+ }
29
+
30
+ export interface GitRunOptions {
31
+ cwd: string
32
+ timeoutMs?: number
33
+ /** Token for http extra header (push/pull in GitHub mode). */
34
+ token?: string
35
+ /** Throw GitError on non-zero exit (default true). */
36
+ mayFail?: boolean
37
+ }
38
+
39
+ const DEFAULT_TIMEOUT_MS = 30_000
40
+
41
+ export function runGit(args: readonly string[], opts: GitRunOptions): Promise<GitResult> {
42
+ const extra: string[] = []
43
+ if (opts.token !== undefined && opts.token !== '') {
44
+ const basic = Buffer.from(`x-access-token:${opts.token}`, 'utf8').toString('base64')
45
+ extra.push('-c', `http.extraHeader=Authorization: Basic ${basic}`)
46
+ }
47
+ return new Promise((resolve, reject) => {
48
+ const child = spawn('git', [...extra, ...args], {
49
+ cwd: opts.cwd,
50
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ })
53
+ let stdout = ''
54
+ let stderr = ''
55
+ let settled = false
56
+ const timer = setTimeout(() => {
57
+ if (settled) return
58
+ settled = true
59
+ child.kill('SIGKILL')
60
+ reject(new GitError(`git ${args[0]} timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, { code: -1, stdout, stderr }))
61
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
62
+ child.stdout.on('data', (d: Buffer) => {
63
+ stdout += d.toString('utf8')
64
+ })
65
+ child.stderr.on('data', (d: Buffer) => {
66
+ stderr += d.toString('utf8')
67
+ })
68
+ child.on('error', (err) => {
69
+ if (settled) return
70
+ settled = true
71
+ clearTimeout(timer)
72
+ reject(err)
73
+ })
74
+ child.on('close', (code) => {
75
+ if (settled) return
76
+ settled = true
77
+ clearTimeout(timer)
78
+ const result: GitResult = { code: code ?? -1, stdout, stderr }
79
+ if ((code ?? -1) !== 0 && opts.mayFail !== true) {
80
+ reject(new GitError(`git ${args[0]} failed (${code}): ${stderr.trim().split('\n').at(-1) ?? ''}`, result))
81
+ } else {
82
+ resolve(result)
83
+ }
84
+ })
85
+ })
86
+ }
87
+
88
+ /** Commit identity so bundle history never depends on the machine's git config. */
89
+ const IDENTITY = ['-c', 'user.name=dsh-topics-memory', '-c', 'user.email=dsh-topics-memory@localhost']
90
+
91
+ export async function isRepo(cwd: string): Promise<boolean> {
92
+ const r = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd, mayFail: true }).catch(() => undefined)
93
+ return r !== undefined && r.code === 0 && r.stdout.trim() === 'true'
94
+ }
95
+
96
+ export async function initRepo(cwd: string, branch = 'main'): Promise<void> {
97
+ await runGit(['init', '-b', branch], { cwd })
98
+ }
99
+
100
+ export async function addAndCommit(cwd: string, paths: readonly string[], message: string): Promise<boolean> {
101
+ await runGit(['add', '--', ...paths], { cwd })
102
+ // Commit only when something is staged (write-through must not spam empty commits).
103
+ const staged = await runGit(['diff', '--cached', '--name-only'], { cwd })
104
+ if (staged.stdout.trim() === '') return false
105
+ const r = await runGit([...IDENTITY, 'commit', '-m', message], { cwd })
106
+ return r.code === 0
107
+ }
108
+
109
+ export async function headRev(cwd: string): Promise<string | undefined> {
110
+ const r = await runGit(['rev-parse', 'HEAD'], { cwd, mayFail: true }).catch(() => undefined)
111
+ if (r === undefined || r.code !== 0) return undefined
112
+ return r.stdout.trim()
113
+ }
114
+
115
+ export interface HistoryEntry {
116
+ hash: string
117
+ date: string
118
+ message: string
119
+ }
120
+
121
+ /** Per-file history — the toolized exit of 「git 可追溯」(need 3). */
122
+ export async function fileHistory(cwd: string, path: string, limit = 50): Promise<HistoryEntry[]> {
123
+ const r = await runGit(
124
+ ['log', '--follow', `--max-count=${limit}`, '--date=iso-strict', '--pretty=%H%x09%ad%x09%s', '--', path],
125
+ { cwd, mayFail: true },
126
+ ).catch(() => undefined)
127
+ if (r === undefined || r.code !== 0) return []
128
+ return r.stdout
129
+ .split('\n')
130
+ .filter((l) => l.trim() !== '')
131
+ .map((l) => {
132
+ const [hash, date, ...rest] = l.split('\t')
133
+ return { hash: hash ?? '', date: date ?? '', message: rest.join('\t') }
134
+ })
135
+ }
136
+
137
+ /** The full content of a topic file at a given revision (conclusion diffing). */
138
+ export async function fileAtRev(cwd: string, path: string, rev: string): Promise<string | undefined> {
139
+ const r = await runGit(['show', `${rev}:${path}`], { cwd, mayFail: true }).catch(() => undefined)
140
+ if (r === undefined || r.code !== 0) return undefined
141
+ return r.stdout
142
+ }
143
+
144
+ export interface PullOutcome {
145
+ ok: boolean
146
+ /** Paths left in conflicted state (empty when ok). */
147
+ conflicted: string[]
148
+ output: string
149
+ }
150
+
151
+ /**
152
+ * `pull --rebase` with conflict containment: on conflict we abort the rebase
153
+ * (ADR 0003 — no automatic smart merging) and report the conflicted paths so
154
+ * the caller can mark those topics. Remote and branch are explicit — the
155
+ * bundle never depends on upstream tracking config.
156
+ */
157
+ export async function pullRebase(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<PullOutcome> {
158
+ const r = await runGit(['pull', '--rebase', '--autostash', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
159
+ return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) } satisfies GitResult
160
+ })
161
+ if (r.code === 0) return { ok: true, conflicted: [], output: r.stdout + r.stderr }
162
+ const conflicted = (
163
+ await runGit(['diff', '--name-only', '--diff-filter=U'], { cwd, mayFail: true }).catch(() => ({ code: -1, stdout: '', stderr: '' }) satisfies GitResult)
164
+ ).stdout
165
+ .split('\n')
166
+ .map((l) => l.trim())
167
+ .filter((l) => l !== '')
168
+ if (conflicted.length > 0) {
169
+ await runGit(['rebase', '--abort'], { cwd, mayFail: true }).catch(() => undefined)
170
+ }
171
+ return { ok: false, conflicted, output: r.stdout + r.stderr }
172
+ }
173
+
174
+ export async function push(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<{ ok: boolean; output: string }> {
175
+ const r = await runGit(['push', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch((e) => {
176
+ return { code: -1, stdout: '', stderr: String(e instanceof Error ? e.message : e) } satisfies GitResult
177
+ })
178
+ return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() }
179
+ }
180
+
181
+ export async function setRemote(cwd: string, url: string, remote = 'origin'): Promise<void> {
182
+ const existing = await runGit(['remote', 'get-url', remote], { cwd, mayFail: true }).catch(() => undefined)
183
+ if (existing !== undefined && existing.code === 0 && existing.stdout.trim() !== '') {
184
+ await runGit(['remote', 'set-url', remote, url], { cwd })
185
+ } else {
186
+ await runGit(['remote', 'add', remote, url], { cwd })
187
+ }
188
+ }
189
+
190
+ export async function removeRemote(cwd: string, remote = 'origin'): Promise<void> {
191
+ await runGit(['remote', 'remove', remote], { cwd, mayFail: true }).catch(() => undefined)
192
+ }
193
+
194
+ export async function hasRemote(cwd: string, remote = 'origin'): Promise<boolean> {
195
+ const r = await runGit(['remote', 'get-url', remote], { cwd, mayFail: true }).catch(() => undefined)
196
+ return r !== undefined && r.code === 0 && r.stdout.trim() !== ''
197
+ }
198
+
199
+ export async function unpushedCount(cwd: string, remote = 'origin', branch = 'main'): Promise<number> {
200
+ const r = await runGit(['rev-list', '--count', `${remote}/${branch}..${branch}`], { cwd, mayFail: true }).catch(() => undefined)
201
+ if (r === undefined || r.code !== 0) return 0
202
+ return Number(r.stdout.trim()) || 0
203
+ }
204
+
205
+ /** True when the remote advertises the branch — empty remotes skip the pull step. */
206
+ export async function remoteBranchExists(cwd: string, token?: string, remote = 'origin', branch = 'main'): Promise<boolean> {
207
+ const r = await runGit(['ls-remote', '--heads', remote, branch], { cwd, token, mayFail: true, timeoutMs: 60_000 }).catch(() => undefined)
208
+ return r !== undefined && r.code === 0 && r.stdout.trim() !== ''
209
+ }
package/src/ilog.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Injection log (ADR 0007) — one record per retrieval round, no conversation
3
+ * text, only features and decisions. Aggregations power `/topics stats` and the
4
+ * near-miss evidence that makes threshold tuning measurable.
5
+ *
6
+ * @module ilog
7
+ */
8
+
9
+ export interface InjectionRecord {
10
+ at: string
11
+ sessionId?: string
12
+ /** Query shape: token count and a bounded sample (features, not content). */
13
+ queryTokenCount: number
14
+ querySample?: string
15
+ rosterSize: number
16
+ hits: { slug: string; score: number; reasons: string[]; viaGraph: boolean }[]
17
+ nearMisses: { slug: string; score: number }[]
18
+ injected: boolean
19
+ why?: string
20
+ dropped?: { slug: string; reason: string }[]
21
+ /** Slugs blocked this round by session-level injection dedup (never assembled). */
22
+ deduped?: string[]
23
+ usedTokens?: number
24
+ }
25
+
26
+ export interface AggregateStats {
27
+ rounds: number
28
+ injectedRounds: number
29
+ hitRate: number
30
+ zeroHitRounds: number
31
+ avgHitsPerRound: number
32
+ topTopics: { slug: string; count: number }[]
33
+ nearMissHistogram: { bucket: string; count: number }[]
34
+ avgBudgetUtilization: number
35
+ }
36
+
37
+ export function aggregateStats(records: readonly InjectionRecord[]): AggregateStats {
38
+ const rounds = records.length
39
+ let injectedRounds = 0
40
+ let zeroHitRounds = 0
41
+ let hitsSum = 0
42
+ let budgetSamples = 0
43
+ let budgetSum = 0
44
+ const topicCounts = new Map<string, number>()
45
+ const buckets = new Map<string, number>()
46
+ for (const r of records) {
47
+ if (r.injected) injectedRounds += 1
48
+ if (r.hits.length === 0) zeroHitRounds += 1
49
+ hitsSum += r.hits.length
50
+ if (typeof r.usedTokens === 'number' && r.usedTokens > 0) {
51
+ budgetSamples += 1
52
+ budgetSum += r.usedTokens
53
+ }
54
+ // topTopics feeds 「Top-N 被注入 Topic」— deduped hits were NOT injected,
55
+ // so they must not inflate the per-slug injection counts. Retrieval-shape
56
+ // metrics above (hits/rounds, zero-hit rounds) keep counting raw hits.
57
+ const deduped = r.deduped === undefined ? undefined : new Set(r.deduped)
58
+ for (const h of r.hits) {
59
+ if (deduped?.has(h.slug)) continue
60
+ topicCounts.set(h.slug, (topicCounts.get(h.slug) ?? 0) + 1)
61
+ }
62
+ for (const nm of r.nearMisses) {
63
+ const bucket = nmBucket(nm.score)
64
+ buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1)
65
+ }
66
+ }
67
+ const topTopics = [...topicCounts.entries()]
68
+ .map(([slug, count]) => ({ slug, count }))
69
+ .sort((a, b) => b.count - a.count)
70
+ .slice(0, 10)
71
+ const nearMissHistogram = [...buckets.entries()]
72
+ .sort((a, b) => bucketOrder(a[0]) - bucketOrder(b[0]))
73
+ .map(([bucket, count]) => ({ bucket, count }))
74
+ return {
75
+ rounds,
76
+ injectedRounds,
77
+ hitRate: rounds === 0 ? 0 : Math.round((injectedRounds / rounds) * 1000) / 1000,
78
+ zeroHitRounds,
79
+ avgHitsPerRound: rounds === 0 ? 0 : Math.round((hitsSum / rounds) * 100) / 100,
80
+ topTopics,
81
+ nearMissHistogram,
82
+ avgBudgetUtilization: budgetSamples === 0 ? 0 : Math.round((budgetSum / budgetSamples) * 100) / 100,
83
+ }
84
+ }
85
+
86
+ function nmBucket(score: number): string {
87
+ const floor = Math.floor(score * 20) / 20
88
+ return `${floor.toFixed(2)}–${(floor + 0.05).toFixed(2)}`
89
+ }
90
+
91
+ function bucketOrder(label: string): number {
92
+ return Number(label.split('–')[0])
93
+ }
94
+
95
+ /** Bounded sample of the query kept for debugging — words only, max 40 chars. */
96
+ export function querySample(query: string): string {
97
+ const words = query.replace(/\s+/g, ' ').trim()
98
+ return words.length <= 40 ? words : `${words.slice(0, 40)}…`
99
+ }