@jinn-network/plugin 0.1.0 → 0.1.1-canary.25281242

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/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export type { ContributionPort, ContributionLedgerEntry, ContributionLocalState,
19
19
  export type { LocalLearningPort, LocalLearningRun, LocalLearningSkill, } from './ports/local-learning-port.js';
20
20
  export type { SkillsPort, SkillRecord } from './ports/skills-port.js';
21
21
  export { createJinnPlugin, JINN_PLUGIN_CONTRACT_VERSION, PluginSession } from './plugin.js';
22
- export type { CompleteSessionEligibilityInputs, CompleteSessionInput, JinnPlugin, JinnPluginDeps, SessionMeta, FirstTurnPickupResult, ToolCallEvent, SessionOutcome, SessionEndResult, ContributionCompletionReceipt, ContributionPreview, } from './plugin.js';
22
+ export type { CompleteSessionEligibilityInputs, CompleteSessionInput, JinnPlugin, JinnPluginDeps, SessionMeta, FirstTurnPickupOptions, FirstTurnPickupResult, ToolCallEvent, SessionOutcome, SessionEndResult, ContributionCompletionReceipt, ContributionPreview, } from './plugin.js';
23
23
  export { PickupConfigSchema, DEFAULT_PICKUP_CONFIG, parsePickupConfig, TIER_ORDER } from './schemas/pickup-config.js';
24
24
  export type { PickupConfig, Tier } from './schemas/pickup-config.js';
25
25
  export { deriveRepositorySearchTerms, discriminatingTerms, deriveSearchTerms, classifyPayload, dedupeKnowledgeHits, scoreKnowledgeHit, selectKnowledgeHits, rankKnowledgeHits, MAX_SELECTED_PACKETS, } from './pickup.js';
package/dist/pickup.d.ts CHANGED
@@ -62,7 +62,15 @@ export declare function scoreKnowledgeHit(hit: KnowledgeHit, terms: string[]): n
62
62
  * cannot manufacture relevance.
63
63
  */
64
64
  export declare function scoreKnowledgeRecord(hit: KnowledgeHit, record: CorpusRecord, terms: string[]): number;
65
- /** Return a copied, deterministically ranked scored-candidate list. */
65
+ /**
66
+ * Return a copied, deterministically ranked scored-candidate list.
67
+ *
68
+ * Recency is a group property, not a pairwise comparator: within each equal
69
+ * score+tier group, use recency only when every hit declares the same domain
70
+ * (including the all-omitted legacy group). A mixed-domain group preserves
71
+ * its stable score/tier input order rather than comparing incomparable
72
+ * recency values or inventing a source priority.
73
+ */
66
74
  export declare function rankScoredKnowledgeHits(candidates: ScoredKnowledgeHit[]): ScoredKnowledgeHit[];
67
75
  /**
68
76
  * Eligible, deduplicated candidates that have at least one metadata match.
@@ -73,8 +81,9 @@ export declare function rankKnowledgeCandidates(hits: KnowledgeHit[], terms: str
73
81
  /**
74
82
  * Full ranked candidate pool (rescope §3.3 selection policy): drop skill
75
83
  * hits, dedup, score, apply the relevance floor (honest nothing-found below
76
- * it), rank score desc → tier desc → recency desc every candidate that
77
- * clears the floor, not sliced to `MAX_SELECTED_PACKETS`.
84
+ * it), rank score desc → tier desc → comparable-domain recency desc (or ref
85
+ * for a mixed-domain tie group) — every candidate that clears the floor, not
86
+ * sliced to `MAX_SELECTED_PACKETS`.
78
87
  *
79
88
  * Content-level guards that can only run after a candidate's content is
80
89
  * fetched (mono #1782: post-fetch skill-payload classification, empty-packet
package/dist/pickup.js CHANGED
@@ -293,17 +293,52 @@ function tierRank(tier) {
293
293
  return -1;
294
294
  return TIER_ORDER.indexOf(tier);
295
295
  }
296
- function compareScoredKnowledgeHits(a, b) {
296
+ function compareScoreAndTier(a, b) {
297
297
  if (b.score !== a.score)
298
298
  return b.score - a.score;
299
- const tierDiff = tierRank(b.hit.tier) - tierRank(a.hit.tier);
300
- if (tierDiff !== 0)
301
- return tierDiff;
302
- return (b.hit.publishedAt ?? 0) - (a.hit.publishedAt ?? 0);
299
+ return tierRank(b.hit.tier) - tierRank(a.hit.tier);
303
300
  }
304
- /** Return a copied, deterministically ranked scored-candidate list. */
301
+ function compareRef(a, b) {
302
+ if (a.hit.ref < b.hit.ref)
303
+ return -1;
304
+ if (a.hit.ref > b.hit.ref)
305
+ return 1;
306
+ return 0;
307
+ }
308
+ /**
309
+ * Return a copied, deterministically ranked scored-candidate list.
310
+ *
311
+ * Recency is a group property, not a pairwise comparator: within each equal
312
+ * score+tier group, use recency only when every hit declares the same domain
313
+ * (including the all-omitted legacy group). A mixed-domain group preserves
314
+ * its stable score/tier input order rather than comparing incomparable
315
+ * recency values or inventing a source priority.
316
+ */
305
317
  export function rankScoredKnowledgeHits(candidates) {
306
- return [...candidates].sort(compareScoredKnowledgeHits);
318
+ const grouped = [...candidates].sort(compareScoreAndTier);
319
+ const ranked = [];
320
+ let start = 0;
321
+ while (start < grouped.length) {
322
+ let end = start + 1;
323
+ while (end < grouped.length
324
+ && compareScoreAndTier(grouped[start], grouped[end]) === 0) {
325
+ end += 1;
326
+ }
327
+ const tieGroup = grouped.slice(start, end);
328
+ const recencyDomain = tieGroup[0].hit.recencyDomain;
329
+ const comparableRecency = tieGroup.every((candidate) => candidate.hit.recencyDomain === recencyDomain);
330
+ if (comparableRecency) {
331
+ tieGroup.sort((a, b) => {
332
+ const recencyDiff = (b.hit.publishedAt ?? 0) - (a.hit.publishedAt ?? 0);
333
+ if (recencyDiff !== 0)
334
+ return recencyDiff;
335
+ return compareRef(a, b);
336
+ });
337
+ }
338
+ ranked.push(...tieGroup);
339
+ start = end;
340
+ }
341
+ return ranked;
307
342
  }
308
343
  /**
309
344
  * Eligible, deduplicated candidates that have at least one metadata match.
@@ -320,8 +355,9 @@ export function rankKnowledgeCandidates(hits, terms) {
320
355
  /**
321
356
  * Full ranked candidate pool (rescope §3.3 selection policy): drop skill
322
357
  * hits, dedup, score, apply the relevance floor (honest nothing-found below
323
- * it), rank score desc → tier desc → recency desc every candidate that
324
- * clears the floor, not sliced to `MAX_SELECTED_PACKETS`.
358
+ * it), rank score desc → tier desc → comparable-domain recency desc (or ref
359
+ * for a mixed-domain tie group) — every candidate that clears the floor, not
360
+ * sliced to `MAX_SELECTED_PACKETS`.
325
361
  *
326
362
  * Content-level guards that can only run after a candidate's content is
327
363
  * fetched (mono #1782: post-fetch skill-payload classification, empty-packet
package/dist/plugin.d.ts CHANGED
@@ -50,10 +50,14 @@ export interface FirstTurnPickupResult {
50
50
  retrievalFired: boolean;
51
51
  eligibleRefs: string[];
52
52
  deliveredRefs: string[];
53
+ deliveredCanonicalEpisodeIds: string[];
53
54
  deliveryMode: 'delivered' | 'disabled' | 'degraded' | 'withheld';
54
55
  deliveredContentHash?: string;
55
56
  degraded?: string;
56
57
  }
58
+ export interface FirstTurnPickupOptions {
59
+ excludeCanonicalEpisodeIds?: readonly string[];
60
+ }
57
61
  export interface ToolCallEvent {
58
62
  spanId: string;
59
63
  parentSpanId: string | null;
@@ -136,7 +140,7 @@ export declare class PluginSession {
136
140
  private deliveredContentHash;
137
141
  private readonly capturedAt;
138
142
  constructor(deps: JinnPluginDeps, meta: SessionMeta);
139
- firstTurnPickup(firstMessage: string): Promise<FirstTurnPickupResult>;
143
+ firstTurnPickup(firstMessage: string, options?: FirstTurnPickupOptions): Promise<FirstTurnPickupResult>;
140
144
  noteUserTurn(content: string): void;
141
145
  noteAssistantTurn(content: string): void;
142
146
  private pushTurn;
package/dist/plugin.js CHANGED
@@ -227,7 +227,8 @@ export class PluginSession {
227
227
  this.deps = deps;
228
228
  this.meta = meta;
229
229
  }
230
- async firstTurnPickup(firstMessage) {
230
+ async firstTurnPickup(firstMessage, options = {}) {
231
+ const excludedCanonicalEpisodeIds = new Set(options.excludeCanonicalEpisodeIds ?? []);
231
232
  const config = parsePickupConfig(this.meta.pickup);
232
233
  if (!config.enabled) {
233
234
  return {
@@ -237,6 +238,7 @@ export class PluginSession {
237
238
  retrievalFired: false,
238
239
  eligibleRefs: [],
239
240
  deliveredRefs: [],
241
+ deliveredCanonicalEpisodeIds: [],
240
242
  deliveryMode: 'disabled',
241
243
  };
242
244
  }
@@ -257,6 +259,7 @@ export class PluginSession {
257
259
  retrievalFired: true,
258
260
  eligibleRefs: [],
259
261
  deliveredRefs: [],
262
+ deliveredCanonicalEpisodeIds: [],
260
263
  deliveryMode: this.deliveryMode,
261
264
  };
262
265
  }
@@ -283,7 +286,19 @@ export class PluginSession {
283
286
  byRef.set(hit.ref, hit);
284
287
  }
285
288
  }
286
- const candidates = rankKnowledgeCandidates([...byRef.values()], scoringTerms);
289
+ // Only the production local search adapter exposes this hit-level hint in
290
+ // this release. Federated search is stable local-then-public, so the first
291
+ // hit for an identity is the deterministic local form to prefer once a
292
+ // fetched public record reveals that same identity.
293
+ const preferredHitByCanonicalEpisodeId = new Map();
294
+ for (const hit of byRef.values()) {
295
+ if (hit.canonicalEpisodeId !== undefined
296
+ && !preferredHitByCanonicalEpisodeId.has(hit.canonicalEpisodeId)) {
297
+ preferredHitByCanonicalEpisodeId.set(hit.canonicalEpisodeId, hit);
298
+ }
299
+ }
300
+ const candidates = rankKnowledgeCandidates([...byRef.values()].filter((hit) => hit.canonicalEpisodeId === undefined
301
+ || !excludedCanonicalEpisodeIds.has(hit.canonicalEpisodeId)), scoringTerms);
287
302
  if (candidates.length === 0) {
288
303
  this.deliveryMode = degradedReason === undefined ? 'withheld' : 'degraded';
289
304
  return {
@@ -293,6 +308,7 @@ export class PluginSession {
293
308
  retrievalFired: true,
294
309
  eligibleRefs: [],
295
310
  deliveredRefs: [],
311
+ deliveredCanonicalEpisodeIds: [],
296
312
  deliveryMode: this.deliveryMode,
297
313
  ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
298
314
  };
@@ -307,25 +323,78 @@ export class PluginSession {
307
323
  // floor and records an honest degraded reason (fail open).
308
324
  const fetchedRefs = [];
309
325
  const prefetchedByRef = new Map();
326
+ const fetchRecord = (hit) => {
327
+ const prefetched = prefetchedByRef.get(hit.ref);
328
+ if (prefetched !== undefined)
329
+ return prefetched;
330
+ fetchedRefs.push(hit.ref);
331
+ const pending = this.deps.corpus.get(hit.ref).catch((error) => degraded(`corpus get rejected for ${hit.ref}: ${errorReason(error)}`));
332
+ prefetchedByRef.set(hit.ref, pending);
333
+ return pending;
334
+ };
335
+ const resolvePreferredRecord = async (hit, fetchedRecord) => {
336
+ let selectedHit = hit;
337
+ let record = fetchedRecord;
338
+ let preferredFailureReason;
339
+ const canonicalEpisodeId = record.canonicalEpisodeId;
340
+ const preferredHit = canonicalEpisodeId === undefined
341
+ ? undefined
342
+ : preferredHitByCanonicalEpisodeId.get(canonicalEpisodeId);
343
+ if (preferredHit !== undefined && preferredHit.ref !== hit.ref) {
344
+ const preferredResult = await fetchRecord(preferredHit);
345
+ if (preferredResult.status === 'unavailable') {
346
+ preferredFailureReason = preferredResult.reason;
347
+ }
348
+ else {
349
+ if (preferredResult.status === 'degraded') {
350
+ preferredFailureReason = preferredResult.reason;
351
+ }
352
+ const preferredRecord = valueOr(preferredResult, null);
353
+ if (preferredRecord !== null) {
354
+ selectedHit = preferredHit;
355
+ record = preferredRecord;
356
+ }
357
+ }
358
+ }
359
+ return {
360
+ selectedHit,
361
+ record,
362
+ ...(preferredFailureReason !== undefined ? { preferredFailureReason } : {}),
363
+ };
364
+ };
310
365
  const directCandidates = candidates.filter((candidate) => candidate.score >= RELEVANCE_FLOOR);
311
366
  const nearMisses = candidates
312
367
  .filter((candidate) => candidate.score < RELEVANCE_FLOOR)
313
368
  .slice(0, MAX_CONTENT_RESCORE_CANDIDATES);
314
- const nearMissResults = await Promise.all(nearMisses.map(({ hit }) => {
315
- fetchedRefs.push(hit.ref);
316
- return this.deps.corpus.get(hit.ref).catch((error) => degraded(`corpus get rejected for ${hit.ref}: ${errorReason(error)}`));
369
+ const nearMissResults = await Promise.all(nearMisses.map(async ({ hit }) => {
370
+ const result = await fetchRecord(hit);
371
+ if (result.status === 'unavailable') {
372
+ return { result, selectedHit: hit, record: null };
373
+ }
374
+ const fetchedRecord = valueOr(result, null);
375
+ if (fetchedRecord === null)
376
+ return { result, selectedHit: hit, record: null };
377
+ if (fetchedRecord.canonicalEpisodeId !== undefined
378
+ && excludedCanonicalEpisodeIds.has(fetchedRecord.canonicalEpisodeId)) {
379
+ return { result, selectedHit: hit, record: null };
380
+ }
381
+ const preferred = await resolvePreferredRecord(hit, fetchedRecord);
382
+ return { result, ...preferred };
317
383
  }));
318
384
  const promotedCandidates = [];
319
385
  for (let index = 0; index < nearMisses.length; index += 1) {
320
386
  const candidate = nearMisses[index];
321
- const result = nearMissResults[index];
322
- prefetchedByRef.set(candidate.hit.ref, result);
323
- if (result.status !== 'ok') {
387
+ const nearMissResult = nearMissResults[index];
388
+ if (nearMissResult.result.status === 'unavailable') {
324
389
  if (degradedReason === undefined)
325
- degradedReason = result.reason;
390
+ degradedReason = nearMissResult.result.reason;
326
391
  continue;
327
392
  }
328
- const record = result.value;
393
+ if (nearMissResult.result.status === 'degraded') {
394
+ degradedReason ??= nearMissResult.result.reason;
395
+ }
396
+ degradedReason ??= nearMissResult.preferredFailureReason;
397
+ const record = nearMissResult.record;
329
398
  if (record === null)
330
399
  continue;
331
400
  if (record.isSkillPayload === true)
@@ -350,40 +419,55 @@ export class PluginSession {
350
419
  retrievalFired: true,
351
420
  eligibleRefs: [],
352
421
  deliveredRefs: [],
422
+ deliveredCanonicalEpisodeIds: [],
353
423
  deliveryMode: this.deliveryMode,
354
424
  ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
355
425
  };
356
426
  }
357
427
  // Fetch full content for ranked candidates and project packets, walking
358
428
  // down the ranked list until MAX_SELECTED_PACKETS valid packets are
359
- // found or candidates are exhausted (mono #1782). Three post-fetch
360
- // guards can disqualify a candidate without spending its slot, promoting
361
- // the next-ranked one: (1) content-level skill classification — excludes
362
- // a legacy skill-shaped record (skill.md step attribute) or a
363
- // jinn.skill.v1-backed record that slipped the wire kind filter, exactly
364
- // as a wire kind:'skill' hit is excluded at selection time; (2)
365
- // retrieval-visibility content verification (#1824, W2) — fail-closed
366
- // where the other two are fail-open; (3) empty-packet honesty — a
367
- // projection with zero excerpts and no synthesis is not evidence. A
368
- // projection failure degrades that one ref to nothing-found rather than
369
- // throwing into the caller (§3.5).
429
+ // found or candidates are exhausted (mono #1782). Post-fetch guards can
430
+ // disqualify a candidate without spending its slot, promoting the
431
+ // next-ranked one: (1) canonical exclusion/dedup; (2) content-level
432
+ // skill classification — which excludes a legacy skill-shaped record
433
+ // (skill.md step attribute) or a jinn.skill.v1-backed record that slipped
434
+ // the wire kind filter, exactly as a wire kind:'skill' hit is excluded at
435
+ // selection time; (3) retrieval-visibility content verification (#1824,
436
+ // W2) — fail-closed where the other guards are fail-open; (4)
437
+ // empty-packet honesty — a projection with zero excerpts and no synthesis
438
+ // is not evidence. A projection failure degrades that one ref to
439
+ // nothing-found rather than throwing into the caller (§3.5).
370
440
  const packets = [];
441
+ const deliveredCanonicalEpisodeIds = [];
442
+ const deliveredCanonicalSet = new Set();
371
443
  for (const hit of ranked) {
372
444
  if (packets.length >= MAX_SELECTED_PACKETS)
373
445
  break;
374
- let result = prefetchedByRef.get(hit.ref);
375
- if (result === undefined) {
376
- fetchedRefs.push(hit.ref);
377
- result = await this.deps.corpus.get(hit.ref).catch((error) => degraded(`corpus get rejected for ${hit.ref}: ${errorReason(error)}`));
378
- }
379
- if (result.status !== 'ok') {
446
+ const result = await fetchRecord(hit);
447
+ if (result.status === 'unavailable') {
380
448
  if (degradedReason === undefined)
381
449
  degradedReason = result.reason;
382
450
  continue;
383
451
  }
384
- const record = result.value;
385
- if (record === null)
452
+ if (result.status === 'degraded')
453
+ degradedReason ??= result.reason;
454
+ const fetchedRecord = valueOr(result, null);
455
+ if (fetchedRecord === null)
456
+ continue;
457
+ const fetchedCanonicalEpisodeId = fetchedRecord.canonicalEpisodeId;
458
+ if (fetchedCanonicalEpisodeId !== undefined
459
+ && (excludedCanonicalEpisodeIds.has(fetchedCanonicalEpisodeId)
460
+ || deliveredCanonicalSet.has(fetchedCanonicalEpisodeId))) {
461
+ continue;
462
+ }
463
+ const { selectedHit, record, preferredFailureReason, } = await resolvePreferredRecord(hit, fetchedRecord);
464
+ degradedReason ??= preferredFailureReason;
465
+ const canonicalEpisodeId = record.canonicalEpisodeId;
466
+ if (canonicalEpisodeId !== undefined
467
+ && (excludedCanonicalEpisodeIds.has(canonicalEpisodeId)
468
+ || deliveredCanonicalSet.has(canonicalEpisodeId))) {
386
469
  continue;
470
+ }
387
471
  if (record.isSkillPayload === true)
388
472
  continue;
389
473
  // Post-fetch content guard (#1824, W2): content is the truth, the
@@ -397,12 +481,16 @@ export class PluginSession {
397
481
  packet = projectKnowledgePacket(record);
398
482
  }
399
483
  catch (error) {
400
- degradedReason ??= `packet projection failed for ${hit.ref}: ${errorReason(error)}`;
484
+ degradedReason ??= `packet projection failed for ${selectedHit.ref}: ${errorReason(error)}`;
401
485
  continue;
402
486
  }
403
487
  if (packet.excerpts.length === 0 && packet.synthesis === undefined)
404
488
  continue;
405
489
  packets.push(packet);
490
+ if (canonicalEpisodeId !== undefined) {
491
+ deliveredCanonicalSet.add(canonicalEpisodeId);
492
+ deliveredCanonicalEpisodeIds.push(canonicalEpisodeId);
493
+ }
406
494
  }
407
495
  this.fetchedRefs = fetchedRefs;
408
496
  this.providedRefs = packets.map((packet) => packet.ref);
@@ -417,6 +505,7 @@ export class PluginSession {
417
505
  retrievalFired: true,
418
506
  eligibleRefs: this.eligibleRefs,
419
507
  deliveredRefs: [],
508
+ deliveredCanonicalEpisodeIds: [],
420
509
  deliveryMode: this.deliveryMode,
421
510
  ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
422
511
  };
@@ -431,6 +520,7 @@ export class PluginSession {
431
520
  retrievalFired: true,
432
521
  eligibleRefs: this.eligibleRefs,
433
522
  deliveredRefs: this.providedRefs,
523
+ deliveredCanonicalEpisodeIds,
434
524
  deliveryMode: this.deliveryMode,
435
525
  deliveredContentHash: this.deliveredContentHash,
436
526
  ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
@@ -536,8 +626,7 @@ function renderKnowledgePacket(packet) {
536
626
  for (const excerpt of packet.excerpts)
537
627
  lines.push(`- ${excerpt.label}: ${excerpt.text}`);
538
628
  const capturedDate = packet.attribution.capturedAt.slice(0, 10);
539
- lines.push(` source: ${packet.ref} · ${packet.attribution.origin} · captured ${capturedDate} · `
540
- + `full episode: corpus_fetch ${packet.ref}`);
629
+ lines.push(` source: ${packet.ref} · ${packet.attribution.origin} · captured ${capturedDate}`);
541
630
  return lines.join('\n');
542
631
  }
543
632
  function renderKnowledgePackets(packets) {
@@ -19,6 +19,11 @@ export interface CorpusRecordStep {
19
19
  */
20
20
  export interface CorpusRecord {
21
21
  ref: string;
22
+ /**
23
+ * Canonical EpisodeV1 episodeId, or the legacy trace sessionId used as its
24
+ * read-compatible identity. Application metadata for deduplication only.
25
+ */
26
+ canonicalEpisodeId?: string;
22
27
  task: {
23
28
  summary: string;
24
29
  repositorySlug?: string;
@@ -2,6 +2,7 @@
2
2
  import { z } from 'zod';
3
3
  export declare const KnowledgeHitSchema: z.ZodObject<{
4
4
  ref: z.ZodString;
5
+ canonicalEpisodeId: z.ZodOptional<z.ZodString>;
5
6
  kind: z.ZodEnum<{
6
7
  seed: "seed";
7
8
  trace: "trace";
@@ -22,6 +23,7 @@ export declare const KnowledgeHitSchema: z.ZodObject<{
22
23
  tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
24
  origin: z.ZodOptional<z.ZodString>;
24
25
  publishedAt: z.ZodOptional<z.ZodNumber>;
26
+ recencyDomain: z.ZodOptional<z.ZodString>;
25
27
  retrievalVisible: z.ZodOptional<z.ZodBoolean>;
26
28
  }, z.core.$strict>;
27
29
  export type KnowledgeHit = z.infer<typeof KnowledgeHitSchema>;
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { TIER_ORDER } from './pickup-config.js';
4
4
  export const KnowledgeHitSchema = z.strictObject({
5
5
  ref: z.string().min(1),
6
+ canonicalEpisodeId: z.string().min(1).optional(),
6
7
  kind: z.enum(['seed', 'trace', 'skill']),
7
8
  title: z.string().min(1).optional(),
8
9
  snippet: z.string().min(1).optional(),
@@ -14,8 +15,15 @@ export const KnowledgeHitSchema = z.strictObject({
14
15
  /** Trustworthy content-dedup identity: on-chain agentId when known, otherwise
15
16
  * the record ref. Never a manifest-supplied safeAddress. */
16
17
  origin: z.string().min(1).optional(),
17
- /** Unix-ms publish timethe selection policy's recency tiebreaker (rescope §3.3). */
18
+ /** Adapter-native recency valuecomparable only within `recencyDomain`. */
18
19
  publishedAt: z.number().int().nonnegative().optional(),
20
+ /**
21
+ * Adapter-declared comparison domain for `publishedAt` (for example,
22
+ * `unix-ms` or `block-number`). Recency is comparable only within one
23
+ * explicit domain. When both hits omit this additive field, ranking keeps
24
+ * the legacy raw-number behavior.
25
+ */
26
+ recencyDomain: z.string().min(1).optional(),
19
27
  /** Computed by the adapter from the wire hit's tags (issue #1824) — presence
20
28
  * of RETRIEVAL_VISIBLE_TAG. Absent = treat as not visible (fail-closed at
21
29
  * ranking). */
@@ -65,11 +65,12 @@ export interface KnowledgePacketBudget {
65
65
  maxChars?: number;
66
66
  }
67
67
  /**
68
- * Line-boundary-aware truncation ending with an explicit, pointer-bearing
69
- * tail. Returns an empty string when the budget cannot fit both meaningful
70
- * source text and the complete tail.
68
+ * Line-boundary-aware truncation ending with a neutral marker. The `ref`
69
+ * parameter remains required for source compatibility with the public helper.
70
+ * Returns an empty string when the budget cannot fit both meaningful source
71
+ * text and the complete tail.
71
72
  */
72
- export declare function truncateLineBoundary(text: string, maxChars: number, ref: string): string;
73
+ export declare function truncateLineBoundary(text: string, maxChars: number, _ref: string): string;
73
74
  /**
74
75
  * Pure deterministic projection of a `CorpusRecord` into a `KnowledgePacket`
75
76
  * (rescope §3.2). Selects and truncates; never paraphrases. Enforces the
@@ -60,6 +60,9 @@ function stepDiff(step) {
60
60
  function stepNote(step) {
61
61
  return attrString(step.attributes, 'note') ?? attrString(step.attributes, 'turn.text');
62
62
  }
63
+ function isAssistantTurn(step) {
64
+ return attrString(step.attributes, 'role') === 'assistant';
65
+ }
63
66
  const EXCERPT_LABELS = new Set([
64
67
  'failure', 'fix', 'command', 'diff', 'note',
65
68
  ]);
@@ -90,7 +93,9 @@ function seedStepExcerpt(step) {
90
93
  * output, the next command after it that is not itself a failure (the
91
94
  * correction), the last passing command overall (the final backstop), and a
92
95
  * diff step when present. Falls back to a single free-form note when
93
- * nothing command-shaped is found. Selects only never paraphrases.
96
+ * nothing command-shaped is found, preferring an assistant turn so a
97
+ * conversational episode supplies the retained answer rather than merely
98
+ * repeating the user's prompt. Selects only — never paraphrases.
94
99
  */
95
100
  function selectExcerpts(steps) {
96
101
  const seedExcerpts = steps
@@ -135,21 +140,23 @@ function selectExcerpts(steps) {
135
140
  if (diffStep !== undefined)
136
141
  excerpts.push({ label: 'diff', text: stepDiff(diffStep) });
137
142
  if (excerpts.length === 0) {
138
- const noteStep = steps.find((step) => stepNote(step) !== undefined);
143
+ const noteStep = steps.find((step) => isAssistantTurn(step) && stepNote(step) !== undefined)
144
+ ?? steps.find((step) => stepNote(step) !== undefined);
139
145
  if (noteStep !== undefined)
140
146
  excerpts.push({ label: 'note', text: stepNote(noteStep) });
141
147
  }
142
148
  return excerpts;
143
149
  }
144
150
  /**
145
- * Line-boundary-aware truncation ending with an explicit, pointer-bearing
146
- * tail. Returns an empty string when the budget cannot fit both meaningful
147
- * source text and the complete tail.
151
+ * Line-boundary-aware truncation ending with a neutral marker. The `ref`
152
+ * parameter remains required for source compatibility with the public helper.
153
+ * Returns an empty string when the budget cannot fit both meaningful source
154
+ * text and the complete tail.
148
155
  */
149
- export function truncateLineBoundary(text, maxChars, ref) {
156
+ export function truncateLineBoundary(text, maxChars, _ref) {
150
157
  if (text.length <= maxChars)
151
158
  return text;
152
- const tail = `\n[truncated — full episode: corpus_fetch ${ref}]`;
159
+ const tail = '\n[truncated]';
153
160
  const budget = maxChars - tail.length;
154
161
  if (budget <= 0)
155
162
  return '';
@@ -6,7 +6,7 @@ import type { KnowledgeHit } from '../schemas/knowledge-hit.js';
6
6
  * search would return for it (kind/title/tier/payloadKind/publishedAt — the
7
7
  * fields not derivable from `CorpusRecord` alone).
8
8
  */
9
- export type InMemoryCorpusSeed = CorpusRecord & Pick<KnowledgeHit, 'kind'> & Partial<Pick<KnowledgeHit, 'title' | 'tier' | 'payloadKind' | 'publishedAt'>>;
9
+ export type InMemoryCorpusSeed = CorpusRecord & Pick<KnowledgeHit, 'kind'> & Partial<Pick<KnowledgeHit, 'title' | 'tier' | 'payloadKind' | 'publishedAt' | 'recencyDomain'>>;
10
10
  /** Map/array-backed, seedable — architecture spec §8. Seeds carry full
11
11
  * content (`CorpusRecord`) so `get()` can return it directly; `search()`
12
12
  * derives the lightweight `KnowledgeHit` view. */
@@ -17,6 +17,7 @@ export declare class InMemoryCorpusPort implements CorpusPort {
17
17
  ref: string;
18
18
  kind: "seed" | "trace" | "skill";
19
19
  tags: string[];
20
+ canonicalEpisodeId?: string | undefined;
20
21
  title?: string | undefined;
21
22
  snippet?: string | undefined;
22
23
  score?: number | undefined;
@@ -24,6 +25,7 @@ export declare class InMemoryCorpusPort implements CorpusPort {
24
25
  payloadKind?: "unknown" | "skill" | undefined;
25
26
  origin?: string | undefined;
26
27
  publishedAt?: number | undefined;
28
+ recencyDomain?: string | undefined;
27
29
  retrievalVisible?: boolean | undefined;
28
30
  }[]>>;
29
31
  get(ref: string): Promise<{
@@ -2,6 +2,9 @@ import { ok } from '../outcome.js';
2
2
  function toKnowledgeHit(seed) {
3
3
  return {
4
4
  ref: seed.ref,
5
+ ...(seed.canonicalEpisodeId !== undefined
6
+ ? { canonicalEpisodeId: seed.canonicalEpisodeId }
7
+ : {}),
5
8
  kind: seed.kind,
6
9
  ...(seed.title !== undefined ? { title: seed.title } : {}),
7
10
  snippet: seed.task.summary,
@@ -10,6 +13,7 @@ function toKnowledgeHit(seed) {
10
13
  tags: seed.tags,
11
14
  origin: seed.origin,
12
15
  ...(seed.publishedAt !== undefined ? { publishedAt: seed.publishedAt } : {}),
16
+ ...(seed.recencyDomain !== undefined ? { recencyDomain: seed.recencyDomain } : {}),
13
17
  retrievalVisible: seed.retrievalVisible,
14
18
  };
15
19
  }
@@ -48,7 +52,7 @@ export class InMemoryCorpusPort {
48
52
  if (!record)
49
53
  return ok(null);
50
54
  // Strip the KnowledgeHit-only fields — get() returns the CorpusRecord shape.
51
- const { kind: _kind, title: _title, tier: _tier, payloadKind: _payloadKind, publishedAt: _publishedAt, ...corpusRecord } = record;
55
+ const { kind: _kind, title: _title, tier: _tier, payloadKind: _payloadKind, publishedAt: _publishedAt, recencyDomain: _recencyDomain, ...corpusRecord } = record;
52
56
  return ok(corpusRecord);
53
57
  }
54
58
  }
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@jinn-network/plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1-canary.25281242",
4
4
  "description": "Product core for the Jinn Plugin — ports, product schemas, and the createJinnPlugin factory. Stage 1 foundation package (contracts + in-memory kit only).",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.13.0",
7
7
  "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Jinn-Network/mono.git",
11
+ "directory": "packages/plugin"
12
+ },
8
13
  "main": "./dist/index.js",
9
14
  "types": "./dist/index.d.ts",
10
15
  "files": [