@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/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
|
+
}
|