@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/service.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
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
|
+
|
|
9
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import * as okf from './okf.ts'
|
|
13
|
+
import { searchTopics, tokenize, type RetrievableTopic, type SearchOutcome } from './retrieval.ts'
|
|
14
|
+
import { assembleInjection, type AssembleResult, type DigestInput } from './digest.ts'
|
|
15
|
+
import type { BundleStore, Observation, SaveResult } from './store.ts'
|
|
16
|
+
import type { TopicsConfigValue } from './config.ts'
|
|
17
|
+
import { aggregateStats, querySample, type AggregateStats, type InjectionRecord } from './ilog.ts'
|
|
18
|
+
import { fileHistory, fileAtRev } from './git.ts'
|
|
19
|
+
import type { Sync } from './sync.ts'
|
|
20
|
+
|
|
21
|
+
interface CacheEntry {
|
|
22
|
+
mtimeMs: number
|
|
23
|
+
size: number
|
|
24
|
+
doc: okf.TopicDoc
|
|
25
|
+
slug: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RetrieveOutcome {
|
|
29
|
+
outcome: SearchOutcome
|
|
30
|
+
injection: AssembleResult
|
|
31
|
+
/** Roster was empty or injection disabled — no record written for pure no-ops. */
|
|
32
|
+
recorded: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TopicsService {
|
|
36
|
+
private cache = new Map<string, CacheEntry>()
|
|
37
|
+
readonly store: BundleStore
|
|
38
|
+
private readonly getConfig: () => TopicsConfigValue
|
|
39
|
+
readonly sync?: Sync
|
|
40
|
+
|
|
41
|
+
constructor(store: BundleStore, getConfig: () => TopicsConfigValue, sync?: Sync) {
|
|
42
|
+
this.store = store
|
|
43
|
+
this.getConfig = getConfig
|
|
44
|
+
this.sync = sync
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get cfg(): TopicsConfigValue {
|
|
48
|
+
return this.getConfig()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get githubMode(): boolean {
|
|
52
|
+
return this.cfg.repo !== ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** All retrievable topics with conclusion text, mtime-cached. */
|
|
56
|
+
async roster(): Promise<RetrievableTopic[]> {
|
|
57
|
+
let files: string[]
|
|
58
|
+
try {
|
|
59
|
+
files = (await this.store.listTopics()).map((m) => `${m.slug}.md`)
|
|
60
|
+
} catch {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
const out: RetrievableTopic[] = []
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const path = join(this.store.topicsDir(), file)
|
|
66
|
+
let st
|
|
67
|
+
try {
|
|
68
|
+
st = await stat(path)
|
|
69
|
+
} catch {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const cached = this.cache.get(file)
|
|
73
|
+
let entry = cached
|
|
74
|
+
if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
|
|
75
|
+
try {
|
|
76
|
+
const doc = okf.parseTopicDoc(await readFile(path, 'utf8'))
|
|
77
|
+
entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) }
|
|
78
|
+
this.cache.set(file, entry)
|
|
79
|
+
} catch {
|
|
80
|
+
this.cache.delete(file)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (entry === undefined) continue
|
|
85
|
+
const doc = entry.doc
|
|
86
|
+
out.push({
|
|
87
|
+
slug: entry.slug,
|
|
88
|
+
title: doc.fm.title,
|
|
89
|
+
description: doc.fm.description,
|
|
90
|
+
status: doc.fm.status,
|
|
91
|
+
tags: doc.fm.tags,
|
|
92
|
+
depends: doc.fm.depends,
|
|
93
|
+
links: okf.bodyLinkSlugs(doc.body),
|
|
94
|
+
generatedAt: doc.fm.generated.at,
|
|
95
|
+
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Drop the roster cache (tests, bulk external edits). */
|
|
102
|
+
invalidate(): void {
|
|
103
|
+
this.cache.clear()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Full retrieval round: search → assemble → log (ADR 0006/0007). The
|
|
108
|
+
* injection record is written even for zero-hit rounds — the hit-rate
|
|
109
|
+
* denominator must count every round (near-miss evidence).
|
|
110
|
+
*/
|
|
111
|
+
async retrieve(query: string, sessionId?: string): Promise<RetrieveOutcome> {
|
|
112
|
+
const cfg = this.cfg
|
|
113
|
+
const roster = await this.roster()
|
|
114
|
+
if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
outcome: { hits: [], nearMisses: [], rosterSize: roster.length },
|
|
117
|
+
injection: { text: '', usedTokens: 0, included: [], dropped: [] },
|
|
118
|
+
recorded: false,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const conflicts = await this.store.getConflicts()
|
|
122
|
+
const outcome = searchTopics(query, roster, {
|
|
123
|
+
threshold: cfg.matchThreshold,
|
|
124
|
+
topK: cfg.topK,
|
|
125
|
+
tagBoost: cfg.tagBoost,
|
|
126
|
+
graphDepth: cfg.graphDepth,
|
|
127
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
128
|
+
conflicts,
|
|
129
|
+
})
|
|
130
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
131
|
+
const entries: DigestInput[] = []
|
|
132
|
+
for (const hit of outcome.hits) {
|
|
133
|
+
const topic = bySlug.get(hit.slug)
|
|
134
|
+
if (topic === undefined) continue
|
|
135
|
+
const doc = await this.readDoc(hit.slug)
|
|
136
|
+
if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
|
|
137
|
+
}
|
|
138
|
+
const injection = assembleInjection(entries, {
|
|
139
|
+
perTopicBudget: cfg.perTopicBudget,
|
|
140
|
+
totalBudget: cfg.totalBudget,
|
|
141
|
+
})
|
|
142
|
+
const record: InjectionRecord = {
|
|
143
|
+
at: new Date().toISOString(),
|
|
144
|
+
queryTokenCount: tokenize(query).length,
|
|
145
|
+
querySample: querySample(query),
|
|
146
|
+
rosterSize: outcome.rosterSize,
|
|
147
|
+
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
|
|
148
|
+
nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
|
|
149
|
+
injected: injection.text !== '',
|
|
150
|
+
usedTokens: injection.usedTokens,
|
|
151
|
+
}
|
|
152
|
+
if (sessionId !== undefined) record.sessionId = sessionId
|
|
153
|
+
if (injection.text === '' && outcome.hits.length > 0) record.why = 'below-budget-or-dropped'
|
|
154
|
+
if (inclusionDropped(injection)) record.dropped = injection.dropped
|
|
155
|
+
await this.store.appendInjectionRecord(record)
|
|
156
|
+
return { outcome, injection, recorded: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* SYNCHRONOUS hot path for same-turn injection (the chancelu lesson):
|
|
161
|
+
* `agent/inbox/spliced` dispatches before prompt assembly and the context
|
|
162
|
+
* provider reads the assembled text synchronously — an async retrieval
|
|
163
|
+
* would always lose the race and inject nothing. Reads files with sync fs
|
|
164
|
+
* behind the mtime cache; the log record is written fire-and-forget.
|
|
165
|
+
*
|
|
166
|
+
* `dedup.exclude` (session-level injection dedup) filters hits AFTER
|
|
167
|
+
* retrieval and BEFORE assembly: excluded slugs are reported back as
|
|
168
|
+
* `deduped` (and logged as `record.deduped`), never packed. `included`
|
|
169
|
+
* mirrors what actually entered the context — only these may be marked
|
|
170
|
+
* as injected by the caller; budget-dropped slugs stay injectable later.
|
|
171
|
+
*/
|
|
172
|
+
retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[] } {
|
|
173
|
+
const cfg = this.cfg
|
|
174
|
+
const roster = this.rosterSync()
|
|
175
|
+
const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
176
|
+
if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
|
|
177
|
+
return { text: '', outcome: empty, deduped: [], included: [] }
|
|
178
|
+
}
|
|
179
|
+
const conflicts = this.store.getConflictsSync()
|
|
180
|
+
const outcome = searchTopics(query, roster, {
|
|
181
|
+
threshold: cfg.matchThreshold,
|
|
182
|
+
topK: cfg.topK,
|
|
183
|
+
tagBoost: cfg.tagBoost,
|
|
184
|
+
graphDepth: cfg.graphDepth,
|
|
185
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
186
|
+
conflicts,
|
|
187
|
+
})
|
|
188
|
+
const exclude = dedup?.exclude
|
|
189
|
+
const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
|
|
190
|
+
const entries: DigestInput[] = []
|
|
191
|
+
for (const hit of outcome.hits) {
|
|
192
|
+
if (exclude?.has(hit.slug)) continue
|
|
193
|
+
const doc = this.readDocSync(hit.slug)
|
|
194
|
+
if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
|
|
195
|
+
}
|
|
196
|
+
const injection = assembleInjection(entries, {
|
|
197
|
+
perTopicBudget: cfg.perTopicBudget,
|
|
198
|
+
totalBudget: cfg.totalBudget,
|
|
199
|
+
})
|
|
200
|
+
const record: InjectionRecord = {
|
|
201
|
+
at: new Date().toISOString(),
|
|
202
|
+
queryTokenCount: tokenize(query).length,
|
|
203
|
+
querySample: querySample(query),
|
|
204
|
+
rosterSize: outcome.rosterSize,
|
|
205
|
+
hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph })),
|
|
206
|
+
nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score })),
|
|
207
|
+
injected: injection.text !== '',
|
|
208
|
+
usedTokens: injection.usedTokens,
|
|
209
|
+
}
|
|
210
|
+
if (sessionId !== undefined) record.sessionId = sessionId
|
|
211
|
+
if (deduped.length > 0) record.deduped = deduped
|
|
212
|
+
if (injection.text === '' && outcome.hits.length > 0) {
|
|
213
|
+
// All hits deduped is a different story than a budget squeeze — say so.
|
|
214
|
+
record.why = deduped.length === outcome.hits.length ? 'dedup' : 'below-budget-or-dropped'
|
|
215
|
+
}
|
|
216
|
+
if (injection.dropped.length > 0) record.dropped = injection.dropped
|
|
217
|
+
void this.store.appendInjectionRecord(record).catch(() => undefined)
|
|
218
|
+
return { text: injection.text, outcome, deduped, included: injection.included }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Sync roster read (same mtime cache as roster()). */
|
|
222
|
+
rosterSync(): RetrievableTopic[] {
|
|
223
|
+
let files: string[]
|
|
224
|
+
try {
|
|
225
|
+
files = readdirSync(this.store.topicsDir())
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
const out: RetrievableTopic[] = []
|
|
230
|
+
for (const file of files) {
|
|
231
|
+
if (!file.endsWith('.md') || file === 'index.md') continue
|
|
232
|
+
const path = join(this.store.topicsDir(), file)
|
|
233
|
+
let st
|
|
234
|
+
try {
|
|
235
|
+
st = statSync(path)
|
|
236
|
+
} catch {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
const cached = this.cache.get(file)
|
|
240
|
+
let entry = cached
|
|
241
|
+
if (cached === undefined || cached.mtimeMs !== st.mtimeMs || cached.size !== st.size) {
|
|
242
|
+
try {
|
|
243
|
+
const doc = okf.parseTopicDoc(readFileSync(path, 'utf8'))
|
|
244
|
+
entry = { mtimeMs: st.mtimeMs, size: st.size, doc, slug: file.slice(0, -3) }
|
|
245
|
+
this.cache.set(file, entry)
|
|
246
|
+
} catch {
|
|
247
|
+
this.cache.delete(file)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (entry === undefined) continue
|
|
252
|
+
const doc = entry.doc
|
|
253
|
+
out.push({
|
|
254
|
+
slug: entry.slug,
|
|
255
|
+
title: doc.fm.title,
|
|
256
|
+
description: doc.fm.description,
|
|
257
|
+
status: doc.fm.status,
|
|
258
|
+
tags: doc.fm.tags,
|
|
259
|
+
depends: doc.fm.depends,
|
|
260
|
+
links: okf.bodyLinkSlugs(doc.body),
|
|
261
|
+
generatedAt: doc.fm.generated.at,
|
|
262
|
+
conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private readDocSync(slug: string): okf.TopicDoc | undefined {
|
|
269
|
+
try {
|
|
270
|
+
return okf.parseTopicDoc(readFileSync(join(this.store.topicsDir(), `${slug}.md`), 'utf8'))
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async readDoc(slug: string): Promise<okf.TopicDoc | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
return await this.store.readTopic(slug)
|
|
279
|
+
} catch {
|
|
280
|
+
return undefined
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** topic_save tool path: create or update with actor stamping. */
|
|
285
|
+
async saveTopic(input: {
|
|
286
|
+
title: string
|
|
287
|
+
description?: string
|
|
288
|
+
tags?: string[]
|
|
289
|
+
depends?: string[]
|
|
290
|
+
openQuestions?: string[]
|
|
291
|
+
impact?: string[]
|
|
292
|
+
status?: okf.TopicStatus
|
|
293
|
+
conclusion?: string
|
|
294
|
+
recommendations?: string
|
|
295
|
+
source?: 'model' | 'distill'
|
|
296
|
+
slug?: string
|
|
297
|
+
}): Promise<SaveResult & { slug: string }> {
|
|
298
|
+
const now = new Date().toISOString()
|
|
299
|
+
const existing = input.slug !== undefined ? await this.store.readTopic(input.slug) : undefined
|
|
300
|
+
let body = existing?.body ?? ''
|
|
301
|
+
if (input.conclusion !== undefined && input.conclusion !== '') {
|
|
302
|
+
body = okf.setSection(body, okf.CONCLUSION_HEADING, input.conclusion)
|
|
303
|
+
} else if (existing === undefined) {
|
|
304
|
+
body = okf.setSection(body, okf.CONCLUSION_HEADING, input.description ?? input.title)
|
|
305
|
+
}
|
|
306
|
+
if (input.recommendations !== undefined && input.recommendations !== '') {
|
|
307
|
+
body = okf.setSection(body, okf.RECOMMENDATIONS_HEADING, input.recommendations)
|
|
308
|
+
}
|
|
309
|
+
const baseSlug = input.slug ?? okf.slugify(input.title)
|
|
310
|
+
const slug = existing === undefined ? await this.store.uniqueSlug(baseSlug) : okf.slugify(input.slug ?? baseSlug)
|
|
311
|
+
// store.saveTopic stamps generated {by, at} itself (write-through actor).
|
|
312
|
+
const fm: okf.TopicFrontmatter = {
|
|
313
|
+
type: 'Topic',
|
|
314
|
+
title: input.title,
|
|
315
|
+
tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
|
|
316
|
+
depends: (input.depends ?? existing?.fm.depends ?? []).map(okf.slugToPath),
|
|
317
|
+
open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
|
|
318
|
+
impact: input.impact ?? existing?.fm.impact ?? [],
|
|
319
|
+
status: input.status ?? existing?.fm.status ?? 'draft',
|
|
320
|
+
generated: existing?.fm.generated ?? { by: 'pending', at: now },
|
|
321
|
+
}
|
|
322
|
+
if (input.description !== undefined && input.description !== '') fm.description = input.description
|
|
323
|
+
if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
|
|
324
|
+
const result = await this.store.saveTopic(
|
|
325
|
+
{ slug, doc: { fm, body } },
|
|
326
|
+
{ message: existing === undefined ? `topics(topic): create ${slug}` : `topics(topic): update ${slug}`, created: existing === undefined },
|
|
327
|
+
)
|
|
328
|
+
this.invalidate()
|
|
329
|
+
void this.sync?.schedulePush()
|
|
330
|
+
return { ...result, slug: result.slug }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async observe(input: { kind: Observation['kind']; text: string; sessionId?: string; source?: 'model' | 'auto' }): Promise<Observation> {
|
|
334
|
+
return this.store.appendObservation({ kind: input.kind, source: input.source ?? 'model', text: input.text, sessionId: input.sessionId })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async search(query: string, topK?: number): Promise<SearchOutcome> {
|
|
338
|
+
const cfg = this.cfg
|
|
339
|
+
return searchTopics(query, await this.roster(), {
|
|
340
|
+
threshold: cfg.matchThreshold,
|
|
341
|
+
topK: topK ?? Math.max(cfg.topK, 8),
|
|
342
|
+
tagBoost: cfg.tagBoost,
|
|
343
|
+
graphDepth: cfg.graphDepth,
|
|
344
|
+
recencyWindowDays: cfg.recencyWindowDays,
|
|
345
|
+
conflicts: await this.store.getConflicts(),
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async history(slug: string, limit = 20): Promise<{ entries: { hash: string; date: string; message: string; conclusion?: string }[] }> {
|
|
350
|
+
if (!(await this.store.hasGit())) return { entries: [] }
|
|
351
|
+
const path = `topics/${okf.slugify(slug)}.md`
|
|
352
|
+
const log = await fileHistory(this.store.root, path, limit)
|
|
353
|
+
const entries: { hash: string; date: string; message: string; conclusion?: string }[] = []
|
|
354
|
+
for (const e of log) {
|
|
355
|
+
const raw = await fileAtRev(this.store.root, path, e.hash)
|
|
356
|
+
let conclusion: string | undefined
|
|
357
|
+
if (raw !== undefined) {
|
|
358
|
+
try {
|
|
359
|
+
conclusion = firstLine(okf.sectionOf(okf.parseTopicDoc(raw).body, okf.CONCLUSION_HEADING) ?? '')
|
|
360
|
+
} catch {
|
|
361
|
+
conclusion = undefined
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
entries.push({ hash: e.hash.slice(0, 10), date: e.date, message: e.message, conclusion })
|
|
365
|
+
}
|
|
366
|
+
return { entries }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async stats(): Promise<AggregateStats> {
|
|
370
|
+
return aggregateStats(await this.store.readInjectionRecords())
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function inclusionDropped(injection: AssembleResult): boolean {
|
|
375
|
+
return injection.dropped.length > 0
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function firstLine(text: string): string {
|
|
379
|
+
return firstParagraphOf(text)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function firstParagraphOf(text: string): string {
|
|
383
|
+
for (const line of text.split('\n')) {
|
|
384
|
+
const t = line.trim()
|
|
385
|
+
if (t !== '') return t
|
|
386
|
+
}
|
|
387
|
+
return ''
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function dedupeLower(tags: string[]): string[] {
|
|
391
|
+
const seen = new Set<string>()
|
|
392
|
+
const out: string[] = []
|
|
393
|
+
for (const t of tags) {
|
|
394
|
+
const key = t.trim().toLowerCase()
|
|
395
|
+
if (key === '' || seen.has(key)) continue
|
|
396
|
+
seen.add(key)
|
|
397
|
+
out.push(key)
|
|
398
|
+
}
|
|
399
|
+
return out
|
|
400
|
+
}
|