@aiwayds/dsh-topics-memory 0.6.0 → 0.8.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/src/service.ts CHANGED
@@ -10,11 +10,11 @@ import { readFile, stat } from 'node:fs/promises'
10
10
  import { readdirSync, readFileSync, statSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
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'
13
+ import { searchTopics, scoreTopic, passesGate, tokenize, type RetrievableTopic, type SearchOutcome } from './retrieval.ts'
14
+ import { assembleInjection, assemblePointer, POINTER_PER_TOPIC, POINTER_TOTAL, type AssembleResult, type DigestInput, type SlowPointerInput } from './digest.ts'
15
15
  import type { BundleStore, Observation, SaveResult } from './store.ts'
16
16
  import type { TopicsConfigValue } from './config.ts'
17
- import { aggregateStats, querySample, type AggregateStats, type InjectionRecord } from './ilog.ts'
17
+ import { aggregateStats, querySample, type AggregateStats, type InjectionRecord, type QueryBuildShape, type ShadowVerdict, type SlowItem } from './ilog.ts'
18
18
  import { fileHistory, fileAtRev } from './git.ts'
19
19
  import type { Sync } from './sync.ts'
20
20
 
@@ -32,6 +32,20 @@ export interface RetrieveOutcome {
32
32
  recorded: boolean
33
33
  }
34
34
 
35
+ /**
36
+ * Slow-lane payload handed to the hot path at consumption time (v4 §4.2).
37
+ * `items` are the rerank picks (0-2, why lines included); metadata feeds the
38
+ * ilog lane field family. The hot path resolves titles, filters dedup,
39
+ * renders, and takes the log-only shadow verdicts.
40
+ */
41
+ export interface SlowDelivery {
42
+ items: readonly { slug: string; why: string }[]
43
+ computedAt: string
44
+ queryBuild: QueryBuildShape
45
+ model: string
46
+ ms: number
47
+ }
48
+
35
49
  export class TopicsService {
36
50
  private cache = new Map<string, CacheEntry>()
37
51
  readonly store: BundleStore
@@ -89,6 +103,7 @@ export class TopicsService {
89
103
  description: doc.fm.description,
90
104
  status: doc.fm.status,
91
105
  tags: doc.fm.tags,
106
+ triggers: doc.fm.triggers,
92
107
  depends: doc.fm.depends,
93
108
  links: okf.bodyLinkSlugs(doc.body),
94
109
  generatedAt: doc.fm.generated.at,
@@ -135,10 +150,13 @@ export class TopicsService {
135
150
  const doc = await this.readDoc(hit.slug)
136
151
  if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
137
152
  }
138
- const injection = assembleInjection(entries, {
139
- perTopicBudget: cfg.perTopicBudget,
140
- totalBudget: cfg.totalBudget,
141
- })
153
+ const injection =
154
+ cfg.injectMode !== 'digest'
155
+ ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) })
156
+ : assembleInjection(entries, {
157
+ perTopicBudget: cfg.perTopicBudget,
158
+ totalBudget: cfg.totalBudget,
159
+ })
142
160
  const record: InjectionRecord = {
143
161
  at: new Date().toISOString(),
144
162
  queryTokenCount: tokenize(query).length,
@@ -163,18 +181,50 @@ export class TopicsService {
163
181
  * would always lose the race and inject nothing. Reads files with sync fs
164
182
  * behind the mtime cache; the log record is written fire-and-forget.
165
183
  *
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.
184
+ * `dedup.exclude` (session-level injection dedup) filters hits AND slow
185
+ * picks before assembly; excluded slugs are reported back as `deduped`
186
+ * (and logged as `record.deduped`), never packed. `included` mirrors the
187
+ * fast pointers that entered the context and `slowIncluded` the slow ones
188
+ * — only these may be marked as injected by the caller; budget-dropped
189
+ * slugs stay injectable later.
190
+ *
191
+ * `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
192
+ * packed after the fast pointers under the same budget, take log-only
193
+ * shadow re-gate verdicts against the CURRENT query (B3: record, never
194
+ * block), and fill the record's lane field family.
171
195
  */
172
- retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[] } {
196
+ retrieveSync(query: string, sessionId?: string, dedup?: { exclude?: ReadonlySet<string> }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): { text: string; outcome: SearchOutcome; deduped: string[]; included: string[]; slowIncluded: string[] } {
173
197
  const cfg = this.cfg
174
198
  const roster = this.rosterSync()
175
199
  const empty: SearchOutcome = { hits: [], nearMisses: [], rosterSize: roster.length }
176
200
  if (!cfg.autoInject || query.trim() === '' || roster.length === 0) {
177
- return { text: '', outcome: empty, deduped: [], included: [] }
201
+ // A consumed/expired pending must always leave an ilog trace (ADR 0014:
202
+ // 过期/消费都写 ilog) — even when the fast path early-returns here
203
+ // because the roster vanished between production and consumption.
204
+ if (cfg.autoInject && query.trim() !== '' && (slow !== undefined || slowExpired !== undefined)) {
205
+ const trace: InjectionRecord = {
206
+ at: new Date().toISOString(),
207
+ queryTokenCount: tokenize(query).length,
208
+ querySample: querySample(query),
209
+ rosterSize: roster.length,
210
+ hits: [],
211
+ nearMisses: [],
212
+ injected: false,
213
+ why: slowExpired !== undefined ? `slow-expired-${slowExpired}` : 'slow-no-roster',
214
+ lane: 'slow',
215
+ }
216
+ if (sessionId !== undefined) trace.sessionId = sessionId
217
+ if (slowExpired !== undefined) trace.slowExpired = slowExpired
218
+ if (slow !== undefined) {
219
+ trace.computedAt = slow.computedAt
220
+ trace.queryBuild = slow.queryBuild
221
+ trace.slowModel = slow.model
222
+ trace.slowMs = slow.ms
223
+ trace.slow = []
224
+ }
225
+ void this.store.appendInjectionRecord(trace).catch(() => undefined)
226
+ }
227
+ return { text: '', outcome: empty, deduped: [], included: [], slowIncluded: [] }
178
228
  }
179
229
  const conflicts = this.store.getConflictsSync()
180
230
  const outcome = searchTopics(query, roster, {
@@ -187,35 +237,154 @@ export class TopicsService {
187
237
  })
188
238
  const exclude = dedup?.exclude
189
239
  const deduped = exclude === undefined ? [] : outcome.hits.filter((h) => exclude.has(h.slug)).map((h) => h.slug)
240
+ const bySlug = new Map(roster.map((r) => [r.slug, r]))
190
241
  const entries: DigestInput[] = []
242
+ const unreadable: string[] = []
191
243
  for (const hit of outcome.hits) {
192
244
  if (exclude?.has(hit.slug)) continue
193
245
  const doc = this.readDocSync(hit.slug)
194
- if (doc !== undefined) entries.push({ slug: hit.slug, doc, hit })
246
+ if (doc === undefined) {
247
+ // The hit has no readable doc — without this accounting it used to
248
+ // vanish from `included` AND `dropped` (v4 design §2 疑点).
249
+ unreadable.push(hit.slug)
250
+ continue
251
+ }
252
+ entries.push({ slug: hit.slug, doc, hit })
253
+ }
254
+ // ---- Slow-lane merge (log-only shadow gate; consumption bookkeeping) ----
255
+ const slowPointers: SlowPointerInput[] = []
256
+ const slowDelivered: SlowItem[] = []
257
+ const shadow: ShadowVerdict[] = []
258
+ let slowIncluded: string[] = []
259
+ const consumedAt = new Date().toISOString()
260
+ if (slow !== undefined) {
261
+ const queryTokens = new Set(tokenize(query))
262
+ for (const item of slow.items) {
263
+ if (exclude?.has(item.slug)) {
264
+ if (!deduped.includes(item.slug)) deduped.push(item.slug)
265
+ continue
266
+ }
267
+ const meta = bySlug.get(item.slug)
268
+ if (meta === undefined) continue // vanished between production and consumption
269
+ const verdict = scoreTopic(queryTokens, meta, {
270
+ threshold: cfg.matchThreshold,
271
+ topK: cfg.topK,
272
+ tagBoost: cfg.tagBoost,
273
+ graphDepth: cfg.graphDepth,
274
+ recencyWindowDays: cfg.recencyWindowDays,
275
+ conflicts,
276
+ structuralGate: true,
277
+ })
278
+ shadow.push({
279
+ slug: item.slug,
280
+ pass: passesGate(verdict),
281
+ why: verdict.strong ? `strong:${verdict.reasons[0] ?? 'field'}` : `body-hits:${verdict.bodyHits}`,
282
+ })
283
+ slowPointers.push({
284
+ slug: item.slug,
285
+ title: meta.title,
286
+ status: meta.status,
287
+ description: meta.description,
288
+ why: item.why,
289
+ })
290
+ }
291
+ }
292
+ const pointerMode = cfg.injectMode !== 'digest'
293
+ // Pointer budget: per-entry fixed at the design's 80, total clamped to the
294
+ // fast-lane upper bound of 600 (I4) — totalBudget stays tunable downward.
295
+ const injection = pointerMode
296
+ ? assemblePointer(entries, { perTopicBudget: POINTER_PER_TOPIC, totalBudget: Math.min(cfg.totalBudget, POINTER_TOTAL) }, slowPointers)
297
+ : assembleInjection(entries, { perTopicBudget: cfg.perTopicBudget, totalBudget: cfg.totalBudget }, slowPointers)
298
+ slowIncluded = injection.slowIncluded ?? []
299
+ for (const pointer of slowPointers) {
300
+ if (slowIncluded.includes(pointer.slug)) slowDelivered.push({ slug: pointer.slug, why: pointer.why })
195
301
  }
196
- const injection = assembleInjection(entries, {
197
- perTopicBudget: cfg.perTopicBudget,
198
- totalBudget: cfg.totalBudget,
199
- })
200
302
  const record: InjectionRecord = {
201
303
  at: new Date().toISOString(),
202
304
  queryTokenCount: tokenize(query).length,
203
305
  querySample: querySample(query),
204
306
  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 })),
307
+ hits: outcome.hits.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons, viaGraph: h.viaGraph, strong: h.strong, bodyHits: h.bodyHits })),
308
+ // Reasons ride along: the gate-blocked marker is the replay evidence
309
+ // the P3 threshold decision needs (ADR 0014).
310
+ nearMisses: outcome.nearMisses.map((h) => ({ slug: h.slug, score: h.score, reasons: h.reasons })),
207
311
  injected: injection.text !== '',
208
312
  usedTokens: injection.usedTokens,
209
313
  }
210
314
  if (sessionId !== undefined) record.sessionId = sessionId
211
315
  if (deduped.length > 0) record.deduped = deduped
212
- if (injection.text === '' && outcome.hits.length > 0) {
316
+ if (unreadable.length > 0) {
317
+ record.dropped = [...(record.dropped ?? []), ...unreadable.map((slug) => ({ slug, reason: 'doc-unreadable' }))]
318
+ }
319
+ if (slow !== undefined) {
320
+ // Any round whose pending was consumed is a slow-participating round —
321
+ // lane never stays undefined when the lane family is present (a
322
+ // delivered pick with fast pointers is 'mixed'; a consumed-but-empty
323
+ // round beside fast pointers stays 'fast'; everything else 'slow').
324
+ record.lane = slowDelivered.length > 0 ? (injection.included.length > 0 ? 'mixed' : 'slow') : injection.included.length > 0 ? 'fast' : 'slow'
325
+ record.computedAt = slow.computedAt
326
+ record.consumedAt = consumedAt
327
+ if (shadow.length > 0) record.shadowVerdict = shadow
328
+ record.queryBuild = slow.queryBuild
329
+ record.slowModel = slow.model
330
+ record.slowMs = slow.ms
331
+ record.slow = slowDelivered
332
+ }
333
+ if (slowExpired !== undefined) {
334
+ // Expiry is recorded INDEPENDENT of whether the fast lane injected —
335
+ // the 赶上率 denominator must not lose rounds to a coincidental hit.
336
+ record.slowExpired = slowExpired
337
+ if (record.lane === undefined) record.lane = injection.text !== '' ? 'fast' : 'slow'
338
+ }
339
+ if (injection.text === '' && (outcome.hits.length > 0 || slowPointers.length > 0)) {
213
340
  // 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'
341
+ record.why = deduped.length === outcome.hits.length && outcome.hits.length > 0 ? 'dedup' : 'below-budget-or-dropped'
342
+ } else if (injection.text === '' && slowExpired !== undefined) {
343
+ // The pending died at its hard bounds (TTL / turn-lag) without ever
344
+ // being delivered — the denominator the 赶上率 needs (v4 B4).
345
+ record.why = `slow-expired-${slowExpired}`
215
346
  }
216
- if (injection.dropped.length > 0) record.dropped = injection.dropped
347
+ if (slowExpired !== undefined && injection.text === '') record.lane = 'slow'
348
+ if (injection.dropped.length > 0) record.dropped = [...(record.dropped ?? []), ...injection.dropped]
217
349
  void this.store.appendInjectionRecord(record).catch(() => undefined)
218
- return { text: injection.text, outcome, deduped, included: injection.included }
350
+ return { text: injection.text, outcome, deduped, included: injection.included, slowIncluded }
351
+ }
352
+
353
+ /**
354
+ * topic_open tool path (v4 §4.1): full conclusion / open questions /
355
+ * recommendations plus the staleness notice timestamp (fm.generated.at —
356
+ * the store stamps it on every write, so it IS the last-updated instant).
357
+ * Logs an open record for the pointer-open-rate stat.
358
+ */
359
+ async openTopic(slug: string, sessionId?: string): Promise<{
360
+ found: boolean
361
+ slug: string
362
+ title?: string
363
+ status?: okf.TopicStatus
364
+ updatedAt?: string
365
+ description?: string
366
+ conclusion?: string
367
+ openQuestions?: string[]
368
+ recommendations?: string
369
+ }> {
370
+ const doc = await this.store.readTopic(slug).catch(() => undefined)
371
+ if (doc === undefined) return { found: false, slug }
372
+ // Awaited (tool path, not hot): a settled write makes the pointer-open
373
+ // stat reliable; a failed log write must not fail the tool.
374
+ await this.store
375
+ .appendOpenRecord({ slug, at: new Date().toISOString(), ...(sessionId !== undefined ? { sessionId } : {}) })
376
+ .catch(() => undefined)
377
+ return {
378
+ found: true,
379
+ slug,
380
+ title: doc.fm.title,
381
+ status: doc.fm.status,
382
+ updatedAt: doc.fm.generated.at,
383
+ description: doc.fm.description,
384
+ conclusion: okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '',
385
+ openQuestions: doc.fm.open_questions,
386
+ recommendations: okf.sectionOf(doc.body, okf.RECOMMENDATIONS_HEADING) ?? '',
387
+ }
219
388
  }
220
389
 
221
390
  /** Sync roster read (same mtime cache as roster()). */
@@ -256,6 +425,7 @@ export class TopicsService {
256
425
  description: doc.fm.description,
257
426
  status: doc.fm.status,
258
427
  tags: doc.fm.tags,
428
+ triggers: doc.fm.triggers,
259
429
  depends: doc.fm.depends,
260
430
  links: okf.bodyLinkSlugs(doc.body),
261
431
  generatedAt: doc.fm.generated.at,
@@ -286,6 +456,7 @@ export class TopicsService {
286
456
  title: string
287
457
  description?: string
288
458
  tags?: string[]
459
+ triggers?: string[]
289
460
  depends?: string[]
290
461
  openQuestions?: string[]
291
462
  impact?: string[]
@@ -320,6 +491,10 @@ export class TopicsService {
320
491
  generated: existing?.fm.generated ?? { by: 'pending', at: now },
321
492
  }
322
493
  if (input.description !== undefined && input.description !== '') fm.description = input.description
494
+ // Triggers pass through on create; on update the caller's value wins, else
495
+ // the stored list survives (same preserve-on-update semantics as tags).
496
+ const triggers = sanitizeTriggers(input.triggers ?? existing?.fm.triggers ?? [])
497
+ if (triggers.length > 0) fm.triggers = triggers
323
498
  if (existing !== undefined && existing.fm.verified !== undefined) fm.verified = existing.fm.verified
324
499
  const result = await this.store.saveTopic(
325
500
  { slug, doc: { fm, body } },
@@ -342,6 +517,9 @@ export class TopicsService {
342
517
  tagBoost: cfg.tagBoost,
343
518
  graphDepth: cfg.graphDepth,
344
519
  recencyWindowDays: cfg.recencyWindowDays,
520
+ // Explicit tool: the model asked for recall, so the injection-only
521
+ // structural gate must not hide results.
522
+ structuralGate: false,
345
523
  conflicts: await this.store.getConflicts(),
346
524
  })
347
525
  }
@@ -398,3 +576,14 @@ function dedupeLower(tags: string[]): string[] {
398
576
  }
399
577
  return out
400
578
  }
579
+
580
+ function sanitizeTriggers(triggers: readonly string[]): string[] {
581
+ const out: string[] = []
582
+ for (const t of triggers) {
583
+ if (typeof t !== 'string') continue
584
+ const key = t.trim()
585
+ if (key === '') continue
586
+ out.push(key)
587
+ }
588
+ return out
589
+ }
package/src/store.ts CHANGED
@@ -493,6 +493,59 @@ export class BundleStore {
493
493
  return out.slice(-limit)
494
494
  }
495
495
 
496
+ // ------------------------------------------------------------------
497
+ // Pointer-open log (v4 §4.3) — one line per topic_open call; feeds the
498
+ // 「指针打开率」 stat. Features only (slug/time), never conversation text.
499
+ // ------------------------------------------------------------------
500
+
501
+ private opensPath(): string {
502
+ return join(this.metaDir(), 'opens.jsonl')
503
+ }
504
+
505
+ async appendOpenRecord(record: { slug: string; at: string; sessionId?: string }): Promise<void> {
506
+ await mkdir(this.metaDir(), { recursive: true })
507
+ await appendFile(this.opensPath(), `${JSON.stringify(record)}\n`, 'utf8')
508
+ await this.compactOpensIfNeeded()
509
+ }
510
+
511
+ private async compactOpensIfNeeded(): Promise<void> {
512
+ const file = this.opensPath()
513
+ let size = 0
514
+ try {
515
+ size = (await readFile(file, 'utf8')).length
516
+ } catch {
517
+ return
518
+ }
519
+ if (size <= 512 * 1024) return
520
+ const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.trim() !== '')
521
+ await atomicWrite(file, lines.slice(-Math.max(1, Math.floor(lines.length / 4))).map((l) => `${l}\n`).join(''))
522
+ }
523
+
524
+ /** Opens in the recent window; tolerant of a torn tail like the other JSONLs. */
525
+ async readOpenRecords(limit = 2000): Promise<{ slug: string; at: string; sessionId?: string }[]> {
526
+ let raw: string
527
+ try {
528
+ raw = await readFile(this.opensPath(), 'utf8')
529
+ } catch {
530
+ return []
531
+ }
532
+ const out: { slug: string; at: string; sessionId?: string }[] = []
533
+ for (const line of raw.split('\n')) {
534
+ if (line.trim() === '') continue
535
+ try {
536
+ const parsed = JSON.parse(line) as { slug?: unknown; at?: unknown; sessionId?: unknown }
537
+ if (typeof parsed.slug === 'string' && typeof parsed.at === 'string') {
538
+ const rec: { slug: string; at: string; sessionId?: string } = { slug: parsed.slug, at: parsed.at }
539
+ if (typeof parsed.sessionId === 'string') rec.sessionId = parsed.sessionId
540
+ out.push(rec)
541
+ }
542
+ } catch {
543
+ // Torn tail line tolerated.
544
+ }
545
+ }
546
+ return out.slice(-limit)
547
+ }
548
+
496
549
  // ------------------------------------------------------------------
497
550
  // Conflicted topics (ADR 0003) — retrieval demotes these
498
551
  // ------------------------------------------------------------------
package/src/tools.ts CHANGED
@@ -28,6 +28,7 @@ export function buildTopicTools(service: TopicsService) {
28
28
  conclusion: { type: 'string', required: true, description: 'The current best conclusion, self-contained markdown prose' },
29
29
  description: { type: 'string', description: 'One-line summary for indexes and snippets' },
30
30
  tags: { ...STRING_ARRAY, description: 'Cross-cutting tags (project, domain, component); lowercased on save' },
31
+ triggers: { ...STRING_ARRAY, description: 'Short recall phrases that should bring this topic back (specific nouns/terms, no generic words)' },
31
32
  depends: { ...STRING_ARRAY, description: 'Slugs of prerequisite topics (topics this one builds on)' },
32
33
  open_questions: { ...STRING_ARRAY, description: 'Unresolved questions this topic still carries' },
33
34
  impact: { ...STRING_ARRAY, description: 'What this conclusion affects: topics, projects, decisions' },
@@ -59,6 +60,7 @@ export function buildTopicTools(service: TopicsService) {
59
60
  conclusion: args.conclusion,
60
61
  description: args.description,
61
62
  tags: args.tags,
63
+ triggers: args.triggers,
62
64
  depends: args.depends,
63
65
  openQuestions: args.open_questions,
64
66
  impact: args.impact,
@@ -71,6 +73,54 @@ export function buildTopicTools(service: TopicsService) {
71
73
  },
72
74
  })
73
75
 
76
+ const topicOpen = defineTool({
77
+ name: 'topic_open',
78
+ description:
79
+ 'Open one topic from the long-term memory in full: conclusion, open questions, recommendations, ' +
80
+ 'plus a staleness notice. Use when a <topic-memory> pointer (or a search hit) is actually relevant — ' +
81
+ 'pointers are one-line hints; this fetches the real content.',
82
+ parameters: {
83
+ slug: { type: 'string', required: true, description: 'Topic slug (from a pointer or topic_search)' },
84
+ },
85
+ output: {
86
+ schema: {
87
+ type: 'object',
88
+ additionalProperties: false,
89
+ properties: {
90
+ found: { type: 'boolean', required: true },
91
+ slug: { type: 'string', required: true },
92
+ title: { type: 'string' },
93
+ status: { type: 'string' },
94
+ updatedAt: { type: 'string' },
95
+ description: { type: 'string' },
96
+ conclusion: { type: 'string' },
97
+ openQuestions: { type: 'array', items: { type: 'string' } },
98
+ recommendations: { type: 'string' },
99
+ },
100
+ },
101
+ render: (_args, value) => [
102
+ {
103
+ type: 'text' as const,
104
+ text: !value.found
105
+ ? `Topic \`${value.slug}\` 不存在(可能已合并或改名;用 topic_search 找)。`
106
+ : [
107
+ `快照于 ${value.updatedAt},代码事实以源码为准。`,
108
+ `### ${value.title} [${value.status}] (topics:${value.slug})`,
109
+ value.description ?? '',
110
+ value.conclusion !== '' ? `# Conclusion\n\n${value.conclusion}` : '',
111
+ value.openQuestions !== undefined && value.openQuestions.length > 0 ? `待决: ${value.openQuestions.join(';')}` : '',
112
+ value.recommendations !== '' ? `# Recommendations\n\n${value.recommendations}` : '',
113
+ ]
114
+ .filter((l) => l !== '')
115
+ .join('\n\n'),
116
+ },
117
+ ],
118
+ },
119
+ async execute(args) {
120
+ return await service.openTopic(args.slug)
121
+ },
122
+ })
123
+
74
124
  const topicSearch = defineTool({
75
125
  name: 'topic_search',
76
126
  description:
@@ -215,7 +265,7 @@ export function buildTopicTools(service: TopicsService) {
215
265
  },
216
266
  })
217
267
 
218
- return [topicSave, topicSearch, topicObserve, topicHistory]
268
+ return [topicSave, topicOpen, topicSearch, topicObserve, topicHistory]
219
269
  }
220
270
 
221
271
  function extractConclusion(body: string): string {