@aiwayds/dsh-topics-memory 0.22.0 → 0.23.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/distill.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import * as okf from './okf.ts'
14
14
  import type { TopicsService } from './service.ts'
15
+ import { recordPipelineEvent } from './pipeline-events.ts'
15
16
 
16
17
  // dsh 0.1.7 removed the shared catch-all 'plugin' message-source kind:
17
18
  // MessageSourceMap is now a merge-extensible sum type and every producer
@@ -48,6 +49,8 @@ export interface DistillOp {
48
49
  depends?: string[]
49
50
  open_questions?: string[]
50
51
  impact?: string[]
52
+ /** Semantic-association targets (A2): existing slugs or same-batch op slugs. */
53
+ links?: string[]
51
54
  conclusion?: string
52
55
  recommendations?: string
53
56
  status?: string
@@ -70,12 +73,13 @@ export const SYSTEM_PROMPT = [
70
73
  '任务:把观察沉淀成 Topic 操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
71
74
  '输出格式:{"ops": [...]},每个元素:',
72
75
  ' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"triggers":["应想起本条的触发词"],"depends":["前置topic的slug"],',
73
- ' "open_questions":["未决问题"],"impact":["影响面"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
76
+ ' "open_questions":["未决问题"],"impact":["影响面"],"links":["关联topic的slug"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
74
77
  ' "observed_ids":["obs-..."]}',
75
- ' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"observed_ids":["obs-..."]}',
78
+ ' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"links":["关联topic的slug"],"observed_ids":["obs-..."]}',
76
79
  '规则:',
77
80
  '- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「未蒸馏观察」里列出的 id 中逐字复制(形如 "obs-...");create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
78
- '- create 建议带 triggers:3-8 个「什么输入出现时应该想起这条记忆」的短语,写具体名词/术语,禁止 dsh、配置、错误 之类的泛词。',
81
+ '- 硬性要求:每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 个「什么输入出现时应该想起这条记忆」的短语,写具体名词/术语,禁止 dsh、配置、错误 之类的泛词);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
82
+ '- links(可选输出,不给不惩罚):只建真实语义关联(依赖/同项目/同主题链),宁缺勿滥;每项只能是已存在 topic 的 slug 或本批其他 op 的 slug,列表之外的引用会被剔除并落账。没有真实关联就不要给 links。',
79
83
  '- conclusion 必须自含(不依赖观察原文也能读懂),写「目前有效的结论」,不是流水账。',
80
84
  '- 同一主题只允许一个 create;已有相近 topic 时用 update 修订它的 conclusion。',
81
85
  '- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
@@ -113,6 +117,10 @@ interface BatchOutcome {
113
117
  fatalReason?: 'model-error' | 'invalid-output'
114
118
  /** Batch ids an op actually consumed — the GC's consumed set for this batch. */
115
119
  consumedIds?: string[]
120
+ /** create ops dropped by the schema gate (A6 §5.1) this batch. */
121
+ invalidOps?: number
122
+ /** links stripped by the whitelist (A2) this batch. */
123
+ rejectedLinks?: number
116
124
  detail?: string
117
125
  /**
118
126
  * Specific zero-consumption explanation for the stalled stop (undefined =
@@ -135,6 +143,48 @@ interface AppliedOps {
135
143
  * observed_ids matched the batch — the corrective-retry trigger.
136
144
  */
137
145
  droppedForIds: number
146
+ /** links stripped by the whitelist (A2) while applying this batch. */
147
+ rejectedLinks: number
148
+ }
149
+
150
+ // ---------------------------------------------------------------------
151
+ // A6 §5.1 op schema gate + A2 links whitelist (always on, no config key:
152
+ // the distill quality contract is not a behavior switch — failures are
153
+ // ledgered via pipeline-events, never swallowed silently).
154
+ // ---------------------------------------------------------------------
155
+
156
+ /** create-op trigger bounds (the recall design's 3-8 strong-field contract). */
157
+ const TRIGGERS_MIN = 3
158
+ const TRIGGERS_MAX = 8
159
+
160
+ /** A create op dropped by the schema gate, with the ledger kind it earned. */
161
+ interface SchemaDrop {
162
+ /** Stable op key (slugified title / slug) for the ledger row. */
163
+ key: string
164
+ kind: 'distill-op-invalid' | 'distill-op-retry-exhausted'
165
+ missing: string
166
+ }
167
+
168
+ /** Stable identity for matching an op against its schema-retry re-emission. */
169
+ function opKey(op: DistillOp): string {
170
+ if (op.op === 'update') return typeof op.slug === 'string' ? okf.slugify(op.slug) : ''
171
+ return typeof op.title === 'string' ? okf.slugify(op.title) : ''
172
+ }
173
+
174
+ /**
175
+ * The A6 §5.1 schema check for create ops: description 必填 + triggers 3-8
176
+ * 条. Update ops keep their narrower contract (slug) — the prompt never
177
+ * required metadata fields on a conclusion revision, and gating those would
178
+ * drop every revision op.
179
+ */
180
+ function checkCreateSchema(op: DistillOp): { valid: boolean; missing: string } {
181
+ const problems: string[] = []
182
+ if (typeof op.description !== 'string' || op.description.trim() === '') problems.push('description')
183
+ const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : []
184
+ if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
185
+ problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`)
186
+ }
187
+ return { valid: problems.length === 0, missing: problems.join('、') }
138
188
  }
139
189
 
140
190
  export class Distiller {
@@ -275,6 +325,8 @@ export class Distiller {
275
325
  let failureDetail: string | undefined
276
326
  let firstFetch = true
277
327
  let filteredTotal = 0
328
+ let invalidTotal = 0
329
+ let rejectedTotal = 0
278
330
  let stallDetail: string | undefined
279
331
  // GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
280
332
  // this run (parseable answer, however useless) minus the ids an op
@@ -305,6 +357,8 @@ export class Distiller {
305
357
  calls += Math.max(1, outcome.callsUsed ?? 1)
306
358
  for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
307
359
  filteredTotal += outcome.filteredIds ?? 0
360
+ invalidTotal += outcome.invalidOps ?? 0
361
+ rejectedTotal += outcome.rejectedLinks ?? 0
308
362
  created.push(...outcome.created)
309
363
  updated.push(...outcome.updated)
310
364
  marked += outcome.marked
@@ -383,6 +437,9 @@ export class Distiller {
383
437
  ? `后续批次未消费任何观察,本轮停止${stallDetail !== undefined ? `:${stallDetail}` : ''}`
384
438
  : undefined
385
439
  const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
440
+ const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : ''
441
+ const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : ''
442
+ const notes = `${filteredNote}${schemaNote}${linkNote}`
386
443
  const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
387
444
  result = {
388
445
  ok: true,
@@ -391,10 +448,10 @@ export class Distiller {
391
448
  marked,
392
449
  detail:
393
450
  base === undefined
394
- ? filteredNote === ''
451
+ ? notes === ''
395
452
  ? undefined
396
- : filteredNote.replace(/^;/, '')
397
- : `${base}${filteredNote}`,
453
+ : notes.replace(/^;/, '')
454
+ : `${base}${notes}`,
398
455
  }
399
456
  } else {
400
457
  result = {
@@ -484,6 +541,29 @@ export class Distiller {
484
541
  if (ops.length === 0) {
485
542
  return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
486
543
  }
544
+ // A6 §5.1 schema gate BEFORE any write: deficient creates get exactly one
545
+ // 补问 retry (budget-guarded), still-deficient ops are dropped and
546
+ // ledgered — never a silent drop. Their observations simply stay
547
+ // unconsumed and walk the existing GC/corrective path (three strikes).
548
+ const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft)
549
+ let callsUsed = 1 + schema.retrySpent
550
+ for (const drop of schema.drops) {
551
+ await recordPipelineEvent(this.service.store.root, {
552
+ at: new Date().toISOString(),
553
+ kind: drop.kind,
554
+ detail: { op: drop.key, missing: drop.missing },
555
+ })
556
+ }
557
+ if (schema.finalOps.length === 0) {
558
+ return {
559
+ created: [],
560
+ updated: [],
561
+ marked: 0,
562
+ callsUsed,
563
+ invalidOps: schema.drops.length,
564
+ stallDetail: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
565
+ }
566
+ }
487
567
  // observed_ids are the ONLY bridge between ops and marks — without a valid
488
568
  // id the store cannot mark anything and the batch head can never advance
489
569
  // (the real-machine zero-consumption livelock: topics landed, backlog
@@ -493,10 +573,17 @@ export class Distiller {
493
573
  // corrective retry re-emits it with fixed ids.
494
574
  const validIds = new Set(observations.map((o) => o.id))
495
575
  const idList = observations.map((o) => o.id)
496
- let applied = await this.applyOps(ops, validIds)
497
- let callsUsed = 1
576
+ // Link whitelist universe (A2): slugs existing before this batch plus
577
+ // every same-batch op slug (predicted; the actual create slug joins as
578
+ // each op lands).
579
+ const knownSlugs = new Set<string>(metas.map((m) => m.slug))
580
+ for (const op of schema.finalOps) {
581
+ const key = opKey(op)
582
+ if (key !== '') knownSlugs.add(key)
583
+ }
584
+ let applied = await this.applyOps(schema.finalOps, validIds, knownSlugs)
498
585
  if (applied.consumedIds.length === 0 && applied.droppedForIds > 0) {
499
- if (budgetLeft < 2) {
586
+ if (budgetLeft < callsUsed + 1) {
500
587
  // No free lane: the retry must fit the same run budget.
501
588
  return {
502
589
  created: [],
@@ -504,12 +591,14 @@ export class Distiller {
504
591
  marked: 0,
505
592
  callsUsed,
506
593
  filteredIds: applied.filteredIds,
507
- stallDetail: `ops 未包含有效 observed_ids(已过滤 ${applied.filteredIds} 个无效 id),且模型调用预算不足以纠错重试(剩余 ${budgetLeft - 1} 次)`,
594
+ rejectedLinks: applied.rejectedLinks,
595
+ invalidOps: schema.drops.length,
596
+ stallDetail: `ops 未包含有效 observed_ids(已过滤 ${applied.filteredIds} 个无效 id),且模型调用预算不足以纠错重试(剩余 ${budgetLeft - callsUsed} 次)`,
508
597
  }
509
598
  }
510
599
  const pass1 = applied
511
- const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds)
512
- callsUsed = 2
600
+ const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs)
601
+ callsUsed += 1
513
602
  if (pass2 === undefined || pass2.consumedIds.length === 0) {
514
603
  const tail =
515
604
  pass2 === undefined
@@ -521,6 +610,8 @@ export class Distiller {
521
610
  marked: 0,
522
611
  callsUsed,
523
612
  filteredIds: pass1.filteredIds + (pass2?.filteredIds ?? 0),
613
+ rejectedLinks: pass1.rejectedLinks + (pass2?.rejectedLinks ?? 0),
614
+ invalidOps: schema.drops.length,
524
615
  stallDetail: `${tail};本批 ${observations.length} 条观察零消费`,
525
616
  }
526
617
  }
@@ -531,6 +622,7 @@ export class Distiller {
531
622
  consumedSlugs: pass2.consumedSlugs,
532
623
  filteredIds: pass1.filteredIds + pass2.filteredIds,
533
624
  droppedForIds: pass2.droppedForIds,
625
+ rejectedLinks: pass1.rejectedLinks + pass2.rejectedLinks,
534
626
  }
535
627
  }
536
628
  let marked = 0
@@ -544,6 +636,8 @@ export class Distiller {
544
636
  marked,
545
637
  callsUsed,
546
638
  filteredIds: applied.filteredIds,
639
+ invalidOps: schema.drops.length,
640
+ rejectedLinks: applied.rejectedLinks,
547
641
  consumedIds: applied.consumedIds,
548
642
  }
549
643
  }
@@ -561,6 +655,7 @@ export class Distiller {
561
655
  originalUser: string,
562
656
  idList: readonly string[],
563
657
  validIds: ReadonlySet<string>,
658
+ knownSlugs: ReadonlySet<string>,
564
659
  ): Promise<{ applied?: AppliedOps; why: string }> {
565
660
  const caller = this.caller
566
661
  if (caller === undefined) return { why: 'distill caller unavailable' }
@@ -586,7 +681,115 @@ export class Distiller {
586
681
  return { why: `输出无法解析:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
587
682
  }
588
683
  if (retried.length === 0) return { why: '模型返回空 ops' }
589
- return { applied: await this.applyOps(retried, validIds), why: '' }
684
+ return { applied: await this.applyOps(retried, validIds, knownSlugs), why: '' }
685
+ }
686
+
687
+ /**
688
+ * The A6 §5.1 schema gate, applied to the parsed batch BEFORE any write:
689
+ * create ops must carry description + triggers 3-8 条 (the prompt's standing
690
+ * requirement, now enforced). Deficient creates get exactly ONE 补问 retry
691
+ * that re-asks for just those ops completed — budget-guarded exactly like
692
+ * the corrective ids retry (`retrySpent` reports the extra call). Every
693
+ * still-deficient op is dropped and comes back as a SchemaDrop carrying the
694
+ * ledger kind: `distill-op-invalid` after a used-but-unsuccessful retry,
695
+ * `distill-op-retry-exhausted` when no retry could run or the retry call
696
+ * itself failed. Dropped ops' observations stay unconsumed — the existing
697
+ * GC/corrective path accounts for them, never a silent loss. Update ops
698
+ * keep their narrower contract (slug only).
699
+ */
700
+ private async enforceSchema(
701
+ sessionId: string | undefined,
702
+ originalUser: string,
703
+ ops: readonly DistillOp[],
704
+ budgetLeft: number,
705
+ ): Promise<{ finalOps: DistillOp[]; drops: SchemaDrop[]; retrySpent: number }> {
706
+ const valid: DistillOp[] = []
707
+ const invalid: { op: DistillOp; key: string; missing: string }[] = []
708
+ for (const op of ops) {
709
+ if (op.op !== 'create') {
710
+ valid.push(op)
711
+ continue
712
+ }
713
+ const check = checkCreateSchema(op)
714
+ if (check.valid) valid.push(op)
715
+ else invalid.push({ op, key: opKey(op), missing: check.missing })
716
+ }
717
+ if (invalid.length === 0) return { finalOps: valid, drops: [], retrySpent: 0 }
718
+ if (budgetLeft < 2) {
719
+ return {
720
+ finalOps: valid,
721
+ drops: invalid.map((c) => ({
722
+ key: c.key,
723
+ kind: 'distill-op-retry-exhausted' as const,
724
+ missing: `${c.missing}(模型调用预算不足以补问)`,
725
+ })),
726
+ retrySpent: 0,
727
+ }
728
+ }
729
+ const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid)
730
+ if (rescued === undefined) {
731
+ return {
732
+ finalOps: valid,
733
+ drops: invalid.map((c) => ({
734
+ key: c.key,
735
+ kind: 'distill-op-retry-exhausted' as const,
736
+ missing: `${c.missing}(补问调用失败)`,
737
+ })),
738
+ retrySpent: 1,
739
+ }
740
+ }
741
+ const drops: SchemaDrop[] = []
742
+ for (const c of invalid) {
743
+ const replacement = rescued.get(c.key)
744
+ if (replacement !== undefined && checkCreateSchema(replacement).valid) {
745
+ valid.push(replacement)
746
+ } else {
747
+ drops.push({ key: c.key, kind: 'distill-op-invalid', missing: c.missing })
748
+ }
749
+ }
750
+ return { finalOps: valid, drops, retrySpent: 1 }
751
+ }
752
+
753
+ /**
754
+ * The one schema 补问: re-ask for exactly the deficient creates, completed.
755
+ * Returns key→op for what the model re-emitted (matched by slugified title),
756
+ * or undefined when the call itself failed / output was unparseable — the
757
+ * caller then ledgeres `distill-op-retry-exhausted` for every deficient op.
758
+ */
759
+ private async schemaRetryCall(
760
+ sessionId: string | undefined,
761
+ originalUser: string,
762
+ invalid: readonly { op: DistillOp; key: string; missing: string }[],
763
+ ): Promise<Map<string, DistillOp> | undefined> {
764
+ const caller = this.caller
765
+ if (caller === undefined) return undefined
766
+ const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n')
767
+ const user = [
768
+ originalUser,
769
+ '',
770
+ '---',
771
+ 'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
772
+ list,
773
+ '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。',
774
+ ].join('\n')
775
+ let raw: string
776
+ try {
777
+ raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
778
+ } catch {
779
+ return undefined
780
+ }
781
+ let retried: DistillOp[]
782
+ try {
783
+ retried = parseOps(raw)
784
+ } catch {
785
+ return undefined
786
+ }
787
+ const map = new Map<string, DistillOp>()
788
+ for (const op of retried) {
789
+ const key = opKey(op)
790
+ if (key !== '') map.set(key, op)
791
+ }
792
+ return map
590
793
  }
591
794
 
592
795
  /**
@@ -600,8 +803,24 @@ export class Distiller {
600
803
  * failures (missing fields, unknown update slug) keep the per-op try/catch
601
804
  * isolation — one bad op never sinks the batch, and a stall they cause is
602
805
  * NOT attributed to observed_ids (no corrective retry for those).
806
+ *
807
+ * A2 links: every link target is whitelist-checked against `knownSlugs`
808
+ * (existing slugs ∪ same-batch op slugs); rejected refs are stripped and
809
+ * ledgered (`link-ref-rejected`), survivors ride the op's topic write into
810
+ * the store transaction (先落 topic 再建边 — one write, no separate edge
811
+ * pass). The set is locally seeded with this call's predicted op slugs and
812
+ * grows as creates land, so same-batch references resolve in op order.
603
813
  */
604
- private async applyOps(ops: readonly DistillOp[], validIds: ReadonlySet<string>): Promise<AppliedOps> {
814
+ private async applyOps(
815
+ ops: readonly DistillOp[],
816
+ validIds: ReadonlySet<string>,
817
+ knownSlugs: ReadonlySet<string>,
818
+ ): Promise<AppliedOps> {
819
+ const slugs = new Set<string>(knownSlugs)
820
+ for (const op of ops) {
821
+ const key = opKey(op)
822
+ if (key !== '') slugs.add(key)
823
+ }
605
824
  const applied: AppliedOps = {
606
825
  created: [],
607
826
  updated: [],
@@ -609,6 +828,7 @@ export class Distiller {
609
828
  consumedSlugs: [],
610
829
  filteredIds: 0,
611
830
  droppedForIds: 0,
831
+ rejectedLinks: 0,
612
832
  }
613
833
  for (const op of ops) {
614
834
  try {
@@ -629,6 +849,8 @@ export class Distiller {
629
849
  continue
630
850
  }
631
851
  if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
852
+ const links = await this.acceptLinks(op, slugs)
853
+ applied.rejectedLinks += links.rejected
632
854
  const res = await this.service.saveTopic({
633
855
  title: op.title,
634
856
  conclusion: op.conclusion,
@@ -641,12 +863,16 @@ export class Distiller {
641
863
  recommendations: op.recommendations,
642
864
  status: op.status === 'stable' ? 'stable' : 'draft',
643
865
  source: 'distill',
866
+ links: links.targets,
644
867
  })
645
868
  applied.created.push(res.slug)
646
869
  applied.consumedSlugs.push(res.slug)
870
+ slugs.add(res.slug)
647
871
  } else if (op.op === 'update' && typeof op.slug === 'string') {
648
872
  const existing = await this.service.store.readTopic(op.slug)
649
873
  if (existing === undefined) continue
874
+ const links = await this.acceptLinks(op, slugs)
875
+ applied.rejectedLinks += links.rejected
650
876
  const res = await this.service.saveTopic({
651
877
  title: existing.fm.title,
652
878
  conclusion: typeof op.conclusion === 'string' ? op.conclusion : undefined,
@@ -654,9 +880,11 @@ export class Distiller {
654
880
  status: op.status === 'stable' || op.status === 'deprecated' ? op.status : undefined,
655
881
  slug: op.slug,
656
882
  source: 'distill',
883
+ links: links.targets,
657
884
  })
658
885
  applied.updated.push(res.slug)
659
886
  applied.consumedSlugs.push(res.slug)
887
+ slugs.add(res.slug)
660
888
  } else {
661
889
  continue
662
890
  }
@@ -667,6 +895,39 @@ export class Distiller {
667
895
  }
668
896
  return applied
669
897
  }
898
+
899
+ /**
900
+ * A2 links whitelist: a link survives only when its normalized target is a
901
+ * slug that already exists or belongs to this batch (`knownSlugs`), and not
902
+ * the op's own topic. Everything else is stripped and ledgered
903
+ * (`link-ref-rejected` — a dead edge must at least leave a trace). Returns
904
+ * the surviving targets (undefined when none — no Links section is written)
905
+ * plus the rejection count for the run detail.
906
+ */
907
+ private async acceptLinks(
908
+ op: DistillOp,
909
+ knownSlugs: ReadonlySet<string>,
910
+ ): Promise<{ targets?: string[]; rejected: number }> {
911
+ if (!Array.isArray(op.links) || op.links.length === 0) return { targets: undefined, rejected: 0 }
912
+ const self = opKey(op)
913
+ const accepted = new Set<string>()
914
+ let rejected = 0
915
+ for (const raw of op.links) {
916
+ if (typeof raw !== 'string') continue
917
+ const target = okf.normalizeLinkTarget(raw)
918
+ if (target === undefined || target === self || !knownSlugs.has(target)) {
919
+ await recordPipelineEvent(this.service.store.root, {
920
+ at: new Date().toISOString(),
921
+ kind: 'link-ref-rejected',
922
+ detail: { op: self, ref: raw },
923
+ })
924
+ rejected += 1
925
+ continue
926
+ }
927
+ accepted.add(target)
928
+ }
929
+ return { targets: accepted.size > 0 ? [...accepted] : undefined, rejected }
930
+ }
670
931
  }
671
932
 
672
933
  /** Extract the first balanced JSON object from model output (tolerates fences). */