@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/sync.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub sync layer (ADR 0002/0003/0008) — active only when the `repo`
|
|
3
|
+
* setting is non-empty; local-only mode bypasses the whole module.
|
|
4
|
+
*
|
|
5
|
+
* Semantics: pull --rebase (autostash) on session start and before each
|
|
6
|
+
* push; push debounced (config, default 45s) after write-through commits,
|
|
7
|
+
* hard flush on session end / plugin dispose. A rebase conflict aborts the
|
|
8
|
+
* rebase and marks the conflicted topics — demoted in retrieval with a
|
|
9
|
+
* visible warning, resolved by hand (ADR 0003: no automatic smart merging).
|
|
10
|
+
*
|
|
11
|
+
* @module sync
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import * as gitmod from './git.ts'
|
|
16
|
+
import type { BundleStore } from './store.ts'
|
|
17
|
+
|
|
18
|
+
export function remoteUrlFor(repo: string): string {
|
|
19
|
+
return `https://github.com/${repo}.git`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncConfig {
|
|
23
|
+
/** `owner/name`; empty string = local-only mode. */
|
|
24
|
+
repo: string
|
|
25
|
+
pushDebounceSeconds: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TokenResolver = () => Promise<string | undefined>
|
|
29
|
+
export type UrlResolver = (repo: string) => string
|
|
30
|
+
|
|
31
|
+
export class Sync {
|
|
32
|
+
private timer: NodeJS.Timeout | undefined
|
|
33
|
+
private pending = false
|
|
34
|
+
private inFlight: Promise<void> = Promise.resolve()
|
|
35
|
+
lastError = ''
|
|
36
|
+
lastPushAt?: string
|
|
37
|
+
lastPullAt?: string
|
|
38
|
+
private readonly store: BundleStore
|
|
39
|
+
private readonly cfg: () => SyncConfig
|
|
40
|
+
private readonly token: TokenResolver
|
|
41
|
+
private readonly urlFor: UrlResolver
|
|
42
|
+
|
|
43
|
+
constructor(store: BundleStore, cfg: () => SyncConfig, token: TokenResolver = defaultTokenResolver, urlFor: UrlResolver = remoteUrlFor) {
|
|
44
|
+
this.store = store
|
|
45
|
+
this.cfg = cfg
|
|
46
|
+
this.token = token
|
|
47
|
+
this.urlFor = urlFor
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get active(): boolean {
|
|
51
|
+
return this.cfg().repo !== ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Session start (or `/topics sync pull`): rebase onto remote, mark conflicts. */
|
|
55
|
+
async pull(): Promise<{ ok: boolean; conflicted: string[]; message: string }> {
|
|
56
|
+
if (!this.active) return { ok: true, conflicted: [], message: 'local-only 模式,无同步' }
|
|
57
|
+
const token = await this.safeToken()
|
|
58
|
+
await gitmod.setRemote(this.store.root, this.urlFor(this.cfg().repo))
|
|
59
|
+
if (!(await gitmod.remoteBranchExists(this.store.root, token))) {
|
|
60
|
+
this.lastPullAt = new Date().toISOString()
|
|
61
|
+
return { ok: true, conflicted: [], message: '远端还没有分支,跳过拉取' }
|
|
62
|
+
}
|
|
63
|
+
const outcome = await gitmod.pullRebase(this.store.root, token)
|
|
64
|
+
this.lastPullAt = new Date().toISOString()
|
|
65
|
+
if (outcome.ok) {
|
|
66
|
+
// A successful rebase clears previous conflict marks.
|
|
67
|
+
const prior = await this.store.getConflicts()
|
|
68
|
+
if (prior.size > 0) await this.store.setConflicts([])
|
|
69
|
+
return { ok: true, conflicted: [], message: '已与远端同步' }
|
|
70
|
+
}
|
|
71
|
+
const conflictedSlugs = outcome.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''))
|
|
72
|
+
if (conflictedSlugs.length > 0) {
|
|
73
|
+
await this.store.setConflicts(outcome.conflicted)
|
|
74
|
+
}
|
|
75
|
+
this.lastError = outcome.output.slice(-500)
|
|
76
|
+
return { ok: false, conflicted: conflictedSlugs, message: `rebase 冲突:${conflictedSlugs.join('、') || '未知路径'}(已标记降权,待手工解决)` }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Called after every write-through commit — schedules the debounced push. */
|
|
80
|
+
schedulePush(): void {
|
|
81
|
+
if (!this.active) return
|
|
82
|
+
this.pending = true
|
|
83
|
+
if (this.timer !== undefined) return
|
|
84
|
+
const seconds = Math.max(1, this.cfg().pushDebounceSeconds)
|
|
85
|
+
this.timer = setTimeout(() => {
|
|
86
|
+
this.timer = undefined
|
|
87
|
+
void this.flush()
|
|
88
|
+
}, seconds * 1000)
|
|
89
|
+
// Never hold the process open for a debounced push.
|
|
90
|
+
this.timer.unref?.()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Debounce flush, also used for session end and plugin dispose. */
|
|
94
|
+
async flush(): Promise<{ ok: boolean; message: string }> {
|
|
95
|
+
if (!this.active) return { ok: true, message: 'local-only 模式,无同步' }
|
|
96
|
+
this.pending = false
|
|
97
|
+
if (this.timer !== undefined) {
|
|
98
|
+
clearTimeout(this.timer)
|
|
99
|
+
this.timer = undefined
|
|
100
|
+
}
|
|
101
|
+
const run = this.inFlight.then(async () => {
|
|
102
|
+
if (!(await this.store.hasGit())) return { ok: false, message: 'bundle 不是 git 仓库' }
|
|
103
|
+
const token = await this.safeToken()
|
|
104
|
+
await gitmod.setRemote(this.store.root, this.urlFor(this.cfg().repo))
|
|
105
|
+
if (await gitmod.remoteBranchExists(this.store.root, token)) {
|
|
106
|
+
const pre = await gitmod.pullRebase(this.store.root, token)
|
|
107
|
+
if (!pre.ok) {
|
|
108
|
+
const conflicted = pre.conflicted.map((p) => p.replace(/^topics\//, '').replace(/\.md$/, ''))
|
|
109
|
+
if (conflicted.length > 0) await this.store.setConflicts(pre.conflicted)
|
|
110
|
+
this.lastError = pre.output.slice(-500)
|
|
111
|
+
return { ok: false, message: `push 前 rebase 失败:${conflicted.join('、') || pre.output.slice(-120)}` }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const result = await gitmod.push(this.store.root, token)
|
|
115
|
+
if (result.ok) {
|
|
116
|
+
this.lastPushAt = new Date().toISOString()
|
|
117
|
+
this.lastError = ''
|
|
118
|
+
return { ok: true, message: '已推送到远端' }
|
|
119
|
+
}
|
|
120
|
+
this.lastError = result.output.slice(-500)
|
|
121
|
+
return { ok: false, message: `push 失败:${result.output.slice(-160)}` }
|
|
122
|
+
})
|
|
123
|
+
this.inFlight = run.then(
|
|
124
|
+
() => undefined,
|
|
125
|
+
() => undefined,
|
|
126
|
+
)
|
|
127
|
+
return run
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Commit the meta sidecars (injection log, observations) — flush cadence. */
|
|
131
|
+
async commitMeta(): Promise<void> {
|
|
132
|
+
if (!this.active) return
|
|
133
|
+
await gitmod.addAndCommit(this.store.root, ['meta'], 'topics(meta): flush injection log / observations').catch(() => undefined)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
dispose(): void {
|
|
137
|
+
if (this.timer !== undefined) {
|
|
138
|
+
clearTimeout(this.timer)
|
|
139
|
+
this.timer = undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async safeToken(): Promise<string | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
return await this.token()
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Env first, gh CLI fallback per dsh-vault precedent; login is never our job. */
|
|
153
|
+
async function defaultTokenResolver(): Promise<string | undefined> {
|
|
154
|
+
const env = process.env.GITHUB_TOKEN?.trim()
|
|
155
|
+
if (env !== undefined && env !== '') return env
|
|
156
|
+
return ghCliToken()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** gh CLI fallback per dsh-vault precedent; login is never this plugin's job. */
|
|
160
|
+
async function ghCliToken(): Promise<string | undefined> {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const child = spawn('gh', ['auth', 'token'], { stdio: ['ignore', 'pipe', 'ignore'], env: process.env })
|
|
163
|
+
let out = ''
|
|
164
|
+
child.stdout?.on('data', (d: Buffer) => {
|
|
165
|
+
out += d.toString('utf8')
|
|
166
|
+
})
|
|
167
|
+
child.on('error', () => resolve(undefined))
|
|
168
|
+
child.on('close', (code) => resolve(code === 0 ? out.trim() || undefined : undefined))
|
|
169
|
+
})
|
|
170
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing tools (ADR 0004 explicit channel): `topic_save`, `topic_search`,
|
|
3
|
+
* `topic_observe`, `topic_history`.
|
|
4
|
+
*
|
|
5
|
+
* @module tools
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import type { TopicsService } from './service.ts'
|
|
10
|
+
|
|
11
|
+
const STRING_ARRAY = { type: 'array', items: { type: 'string' } } as const
|
|
12
|
+
|
|
13
|
+
function preview(text: string, max = 240): string {
|
|
14
|
+
const line = text.split('\n').find((l) => l.trim() !== '') ?? ''
|
|
15
|
+
return line.length <= max ? line : `${line.slice(0, max)}…`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildTopicTools(service: TopicsService) {
|
|
19
|
+
const topicSave = defineTool({
|
|
20
|
+
name: 'topic_save',
|
|
21
|
+
description:
|
|
22
|
+
'Create or update one Topic in the long-term topic memory (OKF bundle). ' +
|
|
23
|
+
'Use when a durable conclusion crystallizes: the topic name, what it depends on, open questions, ' +
|
|
24
|
+
'the current conclusion, its impact, and actionable recommendations. ' +
|
|
25
|
+
'Pass `slug` to update an existing topic; omit it to create a new one.',
|
|
26
|
+
parameters: {
|
|
27
|
+
title: { type: 'string', required: true, description: 'Human-readable topic name (immutable identity; renaming creates a new topic)' },
|
|
28
|
+
conclusion: { type: 'string', required: true, description: 'The current best conclusion, self-contained markdown prose' },
|
|
29
|
+
description: { type: 'string', description: 'One-line summary for indexes and snippets' },
|
|
30
|
+
tags: { ...STRING_ARRAY, description: 'Cross-cutting tags (project, domain, component); lowercased on save' },
|
|
31
|
+
depends: { ...STRING_ARRAY, description: 'Slugs of prerequisite topics (topics this one builds on)' },
|
|
32
|
+
open_questions: { ...STRING_ARRAY, description: 'Unresolved questions this topic still carries' },
|
|
33
|
+
impact: { ...STRING_ARRAY, description: 'What this conclusion affects: topics, projects, decisions' },
|
|
34
|
+
recommendations: { type: 'string', description: 'Actionable recommendation following from the conclusion' },
|
|
35
|
+
status: { type: 'string', description: 'Lifecycle: draft (default) | stable | deprecated' },
|
|
36
|
+
slug: { type: 'string', description: 'Existing topic slug to update; omit to create' },
|
|
37
|
+
},
|
|
38
|
+
output: {
|
|
39
|
+
schema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
slug: { type: 'string', required: true },
|
|
44
|
+
path: { type: 'string', required: true },
|
|
45
|
+
created: { type: 'boolean', required: true },
|
|
46
|
+
committed: { type: 'boolean', required: true },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
render: (_args, value) => [
|
|
50
|
+
{
|
|
51
|
+
type: 'text' as const,
|
|
52
|
+
text: `${value.created ? 'Created' : 'Updated'} topic \`${value.slug}\` → ${value.path}${value.committed ? ' (committed)' : ''}`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
async execute(args) {
|
|
57
|
+
const result = await service.saveTopic({
|
|
58
|
+
title: args.title,
|
|
59
|
+
conclusion: args.conclusion,
|
|
60
|
+
description: args.description,
|
|
61
|
+
tags: args.tags,
|
|
62
|
+
depends: args.depends,
|
|
63
|
+
openQuestions: args.open_questions,
|
|
64
|
+
impact: args.impact,
|
|
65
|
+
recommendations: args.recommendations,
|
|
66
|
+
status: args.status === 'draft' || args.status === 'stable' || args.status === 'deprecated' ? args.status : undefined,
|
|
67
|
+
slug: args.slug,
|
|
68
|
+
source: 'model',
|
|
69
|
+
})
|
|
70
|
+
return { slug: result.slug, path: result.path, created: result.created, committed: result.committed }
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const topicSearch = defineTool({
|
|
75
|
+
name: 'topic_search',
|
|
76
|
+
description:
|
|
77
|
+
'Search the long-term topic memory by keywords (hot path, no LLM). ' +
|
|
78
|
+
'Use when the user references past work, or when injected topic memory hints at related history.',
|
|
79
|
+
parameters: {
|
|
80
|
+
query: { type: 'string', required: true, description: 'What to recall from topic memory' },
|
|
81
|
+
top_k: { type: 'number', description: 'Max results (default 8)' },
|
|
82
|
+
},
|
|
83
|
+
output: {
|
|
84
|
+
schema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
properties: {
|
|
88
|
+
results: {
|
|
89
|
+
type: 'array',
|
|
90
|
+
required: true,
|
|
91
|
+
items: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
properties: {
|
|
95
|
+
slug: { type: 'string', required: true },
|
|
96
|
+
title: { type: 'string', required: true },
|
|
97
|
+
status: { type: 'string', required: true },
|
|
98
|
+
score: { type: 'number', required: true },
|
|
99
|
+
preview: { type: 'string', required: true },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
render: (_args, value) => [
|
|
106
|
+
{
|
|
107
|
+
type: 'text' as const,
|
|
108
|
+
text:
|
|
109
|
+
value.results.length === 0
|
|
110
|
+
? 'No topic memory found for this query.'
|
|
111
|
+
: value.results
|
|
112
|
+
.map((r: { slug: string; title: string; status: string; score: number; preview: string }) => `### ${r.title} [${r.status}] (${r.slug} score:${r.score.toFixed(2)})\n${r.preview}`)
|
|
113
|
+
.join('\n\n'),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
async execute(args) {
|
|
118
|
+
const outcome = await service.search(args.query, args.top_k)
|
|
119
|
+
const roster = new Map((await service.roster()).map((r) => [r.slug, r]))
|
|
120
|
+
const docs = await Promise.all(
|
|
121
|
+
outcome.hits.map(async (h) => {
|
|
122
|
+
const doc = await service.store.readTopic(h.slug).catch(() => undefined)
|
|
123
|
+
return { hit: h, doc }
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
results: docs.flatMap(({ hit, doc }) => {
|
|
128
|
+
const meta = roster.get(hit.slug)
|
|
129
|
+
if (doc === undefined || meta === undefined) return []
|
|
130
|
+
const conclusion = extractConclusion(doc.body)
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
slug: hit.slug,
|
|
134
|
+
title: doc.fm.title,
|
|
135
|
+
status: doc.fm.status,
|
|
136
|
+
score: hit.score,
|
|
137
|
+
preview: preview(conclusion !== '' ? conclusion : (doc.fm.description ?? '')),
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
}),
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const topicObserve = defineTool({
|
|
146
|
+
name: 'topic_observe',
|
|
147
|
+
description:
|
|
148
|
+
'Record one atomic observation (a decision, finding, constraint, or open question) as raw material ' +
|
|
149
|
+
'for later distillation into topic memory. Cheap and safe to call mid-task; prefer topic_save when a ' +
|
|
150
|
+
'full conclusion is already settled.',
|
|
151
|
+
parameters: {
|
|
152
|
+
kind: { type: 'string', required: true, description: 'One of: decision | finding | constraint | question' },
|
|
153
|
+
text: { type: 'string', required: true, description: 'The observation, one atomic statement' },
|
|
154
|
+
},
|
|
155
|
+
output: {
|
|
156
|
+
schema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
properties: { id: { type: 'string', required: true } },
|
|
160
|
+
},
|
|
161
|
+
render: (_args, value) => [{ type: 'text' as const, text: `Observed (${value.id})` }],
|
|
162
|
+
},
|
|
163
|
+
async execute(args) {
|
|
164
|
+
const kind = args.kind === 'decision' || args.kind === 'finding' || args.kind === 'constraint' || args.kind === 'question' ? args.kind : 'finding'
|
|
165
|
+
const obs = await service.observe({ kind, text: args.text })
|
|
166
|
+
return { id: obs.id }
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const topicHistory = defineTool({
|
|
171
|
+
name: 'topic_history',
|
|
172
|
+
description:
|
|
173
|
+
'Show the git history of one topic — how its conclusion changed over time, commit by commit. ' +
|
|
174
|
+
'Use when the user asks when/why a conclusion changed or wants to trace a decision.',
|
|
175
|
+
parameters: {
|
|
176
|
+
slug: { type: 'string', required: true, description: 'Topic slug' },
|
|
177
|
+
limit: { type: 'number', description: 'Max commits (default 20)' },
|
|
178
|
+
},
|
|
179
|
+
output: {
|
|
180
|
+
schema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
additionalProperties: false,
|
|
183
|
+
properties: {
|
|
184
|
+
entries: {
|
|
185
|
+
type: 'array',
|
|
186
|
+
required: true,
|
|
187
|
+
items: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
properties: {
|
|
191
|
+
hash: { type: 'string', required: true },
|
|
192
|
+
date: { type: 'string', required: true },
|
|
193
|
+
message: { type: 'string', required: true },
|
|
194
|
+
conclusion: { type: 'string' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
render: (_args, value) => [
|
|
201
|
+
{
|
|
202
|
+
type: 'text' as const,
|
|
203
|
+
text:
|
|
204
|
+
value.entries.length === 0
|
|
205
|
+
? 'No history (topic unknown or bundle not a git repo).'
|
|
206
|
+
: value.entries
|
|
207
|
+
.map((e: { hash: string; date: string; message: string; conclusion?: string }) => `- ${e.hash} ${e.date} ${e.message}${e.conclusion !== undefined ? `\n 结论当时: ${e.conclusion}` : ''}`)
|
|
208
|
+
.join('\n'),
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
async execute(args) {
|
|
213
|
+
const { entries } = await service.history(args.slug, args.limit ?? 20)
|
|
214
|
+
return { entries: entries.map((e) => ({ ...e, conclusion: e.conclusion as string | undefined })) }
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
return [topicSave, topicSearch, topicObserve, topicHistory]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractConclusion(body: string): string {
|
|
222
|
+
// Local copy to avoid importing okf section helpers into the hot render path.
|
|
223
|
+
const m = /^# Conclusion\s*\n+([\s\S]*?)(?=\n# |\s*$)/im.exec(body)
|
|
224
|
+
return m === null ? '' : m[1].trim()
|
|
225
|
+
}
|