@jinn-network/plugin 0.1.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.
Files changed (56) hide show
  1. package/dist/eligibility.d.ts +12 -0
  2. package/dist/eligibility.js +12 -0
  3. package/dist/history.d.ts +35 -0
  4. package/dist/history.js +140 -0
  5. package/dist/index.d.ts +32 -0
  6. package/dist/index.js +15 -0
  7. package/dist/outcome.d.ts +26 -0
  8. package/dist/outcome.js +23 -0
  9. package/dist/pickup.d.ts +101 -0
  10. package/dist/pickup.js +354 -0
  11. package/dist/plugin.d.ts +157 -0
  12. package/dist/plugin.js +586 -0
  13. package/dist/ports/contribution-port.d.ts +53 -0
  14. package/dist/ports/contribution-port.js +14 -0
  15. package/dist/ports/corpus-port.d.ts +63 -0
  16. package/dist/ports/corpus-port.js +1 -0
  17. package/dist/ports/evidence-port.d.ts +17 -0
  18. package/dist/ports/evidence-port.js +1 -0
  19. package/dist/ports/local-learning-port.d.ts +22 -0
  20. package/dist/ports/local-learning-port.js +1 -0
  21. package/dist/ports/skills-port.d.ts +12 -0
  22. package/dist/ports/skills-port.js +1 -0
  23. package/dist/schemas/contribution-candidate.d.ts +116 -0
  24. package/dist/schemas/contribution-candidate.js +63 -0
  25. package/dist/schemas/eligibility-verdict.d.ts +8 -0
  26. package/dist/schemas/eligibility-verdict.js +7 -0
  27. package/dist/schemas/episode.d.ts +432 -0
  28. package/dist/schemas/episode.js +452 -0
  29. package/dist/schemas/history-entry.d.ts +40 -0
  30. package/dist/schemas/history-entry.js +30 -0
  31. package/dist/schemas/knowledge-hit.d.ts +27 -0
  32. package/dist/schemas/knowledge-hit.js +23 -0
  33. package/dist/schemas/knowledge-packet.d.ts +79 -0
  34. package/dist/schemas/knowledge-packet.js +213 -0
  35. package/dist/schemas/pickup-config.d.ts +20 -0
  36. package/dist/schemas/pickup-config.js +34 -0
  37. package/dist/schemas/session-summary.d.ts +17 -0
  38. package/dist/schemas/session-summary.js +19 -0
  39. package/dist/testing/contract-kits.d.ts +12 -0
  40. package/dist/testing/contract-kits.js +201 -0
  41. package/dist/testing/in-memory-contribution.d.ts +93 -0
  42. package/dist/testing/in-memory-contribution.js +104 -0
  43. package/dist/testing/in-memory-corpus.d.ts +47 -0
  44. package/dist/testing/in-memory-corpus.js +54 -0
  45. package/dist/testing/in-memory-evidence.d.ts +335 -0
  46. package/dist/testing/in-memory-evidence.js +23 -0
  47. package/dist/testing/in-memory-local-learning.d.ts +25 -0
  48. package/dist/testing/in-memory-local-learning.js +30 -0
  49. package/dist/testing/in-memory-skills.d.ts +9 -0
  50. package/dist/testing/in-memory-skills.js +16 -0
  51. package/dist/testing.d.ts +6 -0
  52. package/dist/testing.js +8 -0
  53. package/dist/visibility.d.ts +7 -0
  54. package/dist/visibility.js +9 -0
  55. package/package.json +48 -0
  56. package/process-contract.json +8 -0
package/dist/plugin.js ADDED
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Host-neutral Stage 1 product workflow. Embedded hosts buffer their session
3
+ * through `PluginSession`; process hosts pass the already-captured EpisodeV1
4
+ * to `completeSession`. Both paths persist the same canonical evidence and
5
+ * contribution contracts.
6
+ */
7
+ /// <reference types="node" />
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { isDeepStrictEqual } from 'node:util';
10
+ import { EPISODE_SCHEMA_VERSION, EpisodeV1Schema, EpisodeV1WriteSchema, SessionActivityFactsSchema, } from './schemas/episode.js';
11
+ import { ContributionCandidateV1ProjectionSchema, ContributionCandidateV1Schema, } from './schemas/contribution-candidate.js';
12
+ import { projectKnowledgePacket } from './schemas/knowledge-packet.js';
13
+ import { deriveSearchTerms, discriminatingTerms, rankKnowledgeCandidates, rankScoredKnowledgeHits, scoreKnowledgeRecord, MAX_CONTENT_RESCORE_CANDIDATES, MAX_SELECTED_PACKETS, RELEVANCE_FLOOR, } from './pickup.js';
14
+ import { degraded, ok, unavailable, valueOr } from './outcome.js';
15
+ import { parsePickupConfig } from './schemas/pickup-config.js';
16
+ import { deriveEligibility } from './eligibility.js';
17
+ import { foldExplain, foldHistory } from './history.js';
18
+ async function previewContribution(deps, acknowledge) {
19
+ let ledger;
20
+ try {
21
+ ledger = await deps.contribution.ledger();
22
+ }
23
+ catch (error) {
24
+ return unavailable(`contribution preview failed: ${errorReason(error)}`);
25
+ }
26
+ if (ledger.status === 'unavailable')
27
+ return unavailable(ledger.reason);
28
+ const rows = ledger.status === 'ok' ? ledger.value : ledger.value ?? [];
29
+ const row = rows.find((entry) => entry.publicationState === 'preview-required');
30
+ if (!row) {
31
+ return ledger.status === 'degraded' ? degraded(ledger.reason, null) : ok(null);
32
+ }
33
+ if (!row.repositorySlug || !row.baseCommit) {
34
+ return unavailable('contribution preview repository facts unavailable');
35
+ }
36
+ let publicationState = 'preview-required';
37
+ if (acknowledge) {
38
+ let authorization;
39
+ try {
40
+ authorization = await deps.contribution.authorize(row.recordId);
41
+ }
42
+ catch (error) {
43
+ return unavailable(`contribution preview acknowledgement failed: ${errorReason(error)}`);
44
+ }
45
+ if (authorization.status === 'unavailable')
46
+ return unavailable(authorization.reason);
47
+ if (authorization.status === 'degraded') {
48
+ return degraded(authorization.reason);
49
+ }
50
+ publicationState = 'queued';
51
+ }
52
+ const value = {
53
+ recordId: row.recordId,
54
+ repositorySlug: row.repositorySlug,
55
+ baseCommit: row.baseCommit,
56
+ localState: row.localState,
57
+ publicationState,
58
+ status: publicationState,
59
+ acknowledged: acknowledge,
60
+ };
61
+ return ledger.status === 'degraded' ? degraded(ledger.reason, value) : ok(value);
62
+ }
63
+ export const JINN_PLUGIN_CONTRACT_VERSION = 1;
64
+ function errorReason(error) {
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+ /** Titles for `SessionSummary.providedPackets` — the packet's own task summary. */
68
+ function packetTitle(packet) {
69
+ return { ref: packet.ref, title: packet.task.summary };
70
+ }
71
+ async function completeSession(deps, input, hits = { providedPackets: [] }) {
72
+ if (input.contractVersion !== JINN_PLUGIN_CONTRACT_VERSION) {
73
+ throw new Error(`unsupported plugin contract version: ${String(input.contractVersion)}`);
74
+ }
75
+ const capturedEpisode = EpisodeV1Schema.parse(input.episode);
76
+ const activity = SessionActivityFactsSchema.parse(input.activity);
77
+ const eligibility = deriveEligibility({
78
+ status: capturedEpisode.outcome.status,
79
+ verifiabilityTier: capturedEpisode.outcome.verificationStrength,
80
+ retentionPolicy: capturedEpisode.retention.policy,
81
+ publicRepo: input.eligibilityInputs.publicRepo,
82
+ acceptedDiff: input.eligibilityInputs.acceptedDiff,
83
+ }, capturedEpisode.session.capturedAt);
84
+ const embeddedCandidateResult = capturedEpisode.contributionCandidate === undefined
85
+ ? undefined
86
+ : ContributionCandidateV1ProjectionSchema.safeParse(capturedEpisode.contributionCandidate);
87
+ const requestCandidateResult = input.contributionCandidate === undefined
88
+ ? undefined
89
+ : ContributionCandidateV1Schema.safeParse(input.contributionCandidate);
90
+ let resolvedCandidate;
91
+ let contributionUnavailableReason;
92
+ if ((capturedEpisode.contributionCandidate !== undefined || input.contributionCandidate !== undefined)
93
+ && capturedEpisode.session.kind === 'host-internal') {
94
+ contributionUnavailableReason = 'host-internal sessions cannot create contribution candidates';
95
+ }
96
+ else if (embeddedCandidateResult && !embeddedCandidateResult.success) {
97
+ contributionUnavailableReason = 'invalid embedded contribution candidate';
98
+ }
99
+ else if (requestCandidateResult && !requestCandidateResult.success) {
100
+ contributionUnavailableReason = 'invalid contribution candidate';
101
+ }
102
+ else {
103
+ const embeddedCandidate = embeddedCandidateResult?.data;
104
+ const requestCandidate = requestCandidateResult?.data;
105
+ if (embeddedCandidate
106
+ && requestCandidate
107
+ && !isDeepStrictEqual(embeddedCandidate, requestCandidate)) {
108
+ contributionUnavailableReason = 'embedded and request contribution candidates must match';
109
+ }
110
+ else {
111
+ resolvedCandidate = requestCandidate ?? embeddedCandidate;
112
+ if (resolvedCandidate && resolvedCandidate.sourceId !== capturedEpisode.episodeId) {
113
+ contributionUnavailableReason = 'contribution candidate sourceId must match episodeId';
114
+ resolvedCandidate = undefined;
115
+ }
116
+ }
117
+ }
118
+ // A contribution payload must be present before the first immutable evidence
119
+ // write. Invalid or forbidden payloads are dropped so the session evidence
120
+ // itself is still retained.
121
+ const { contributionCandidate: _capturedCandidate, ...capturedWithoutCandidate } = capturedEpisode;
122
+ const episode = EpisodeV1WriteSchema.parse({
123
+ ...capturedWithoutCandidate,
124
+ session: {
125
+ ...capturedEpisode.session,
126
+ kind: capturedEpisode.session.kind ?? 'user',
127
+ },
128
+ origin: capturedEpisode.origin === 'legacy-unstamped'
129
+ ? {
130
+ writer: capturedEpisode.environment.harness.name,
131
+ build: capturedEpisode.environment.harness.version,
132
+ }
133
+ : capturedEpisode.origin,
134
+ activity,
135
+ eligibility,
136
+ ...(resolvedCandidate ? { contributionCandidate: resolvedCandidate } : {}),
137
+ });
138
+ let persistence;
139
+ try {
140
+ persistence = await deps.evidence.put(episode);
141
+ }
142
+ catch (error) {
143
+ persistence = unavailable(`evidence put failed: ${errorReason(error)}`);
144
+ }
145
+ let contribution;
146
+ if (contributionUnavailableReason) {
147
+ contribution = unavailable(contributionUnavailableReason);
148
+ }
149
+ else if (resolvedCandidate !== undefined) {
150
+ const persistedEpisodeId = persistence.status === 'unavailable'
151
+ ? undefined
152
+ : persistence.value?.episodeId;
153
+ if (persistedEpisodeId !== episode.episodeId) {
154
+ contribution = unavailable('contribution reference not recorded because canonical episode persistence was not confirmed');
155
+ }
156
+ else {
157
+ try {
158
+ contribution = await deps.contribution.recordMineable(resolvedCandidate, input.contributionVetoed ? { publicationState: 'vetoed' } : undefined);
159
+ }
160
+ catch (error) {
161
+ contribution = unavailable(`contribution record failed: ${errorReason(error)}`);
162
+ }
163
+ if (contribution.status === 'ok' && input.contributionVetoed === true) {
164
+ const recordId = contribution.value.recordId;
165
+ try {
166
+ const veto = await deps.contribution.veto(recordId);
167
+ contribution = veto.status === 'unavailable'
168
+ ? degraded(veto.reason, { recordId })
169
+ : veto;
170
+ }
171
+ catch (error) {
172
+ contribution = degraded(`contribution veto failed: ${errorReason(error)}`, { recordId });
173
+ }
174
+ }
175
+ else if (contribution.status === 'ok') {
176
+ const recordId = contribution.value.recordId;
177
+ try {
178
+ const snapshot = await deps.contribution.mintStatus(recordId);
179
+ if (snapshot.status === 'ok') {
180
+ contribution = ok({ recordId, ...snapshot.value });
181
+ }
182
+ else if (snapshot.status === 'degraded' && snapshot.value !== undefined) {
183
+ contribution = degraded(snapshot.reason, { recordId, ...snapshot.value });
184
+ }
185
+ else {
186
+ contribution = degraded(snapshot.reason, { recordId });
187
+ }
188
+ }
189
+ catch (error) {
190
+ contribution = degraded(`contribution status failed: ${errorReason(error)}`, { recordId });
191
+ }
192
+ }
193
+ }
194
+ }
195
+ const providedPackets = hits.providedPackets.length > 0
196
+ ? hits.providedPackets.map(packetTitle)
197
+ : activity.providedRefs.map((ref) => ({ ref, title: ref }));
198
+ const summary = {
199
+ episodeRef: episode.episodeId,
200
+ searchedTerms: activity.searchedTerms,
201
+ providedPackets,
202
+ eligibility,
203
+ nothingFound: activity.providedRefs.length === 0,
204
+ };
205
+ return {
206
+ episodeRef: episode.episodeId,
207
+ persistence,
208
+ ...(contribution !== undefined ? { contribution } : {}),
209
+ eligibility,
210
+ summary,
211
+ };
212
+ }
213
+ export class PluginSession {
214
+ deps;
215
+ meta;
216
+ trajectory = [];
217
+ searchedTerms = [];
218
+ providedRefs = [];
219
+ fetchedRefs = [];
220
+ packets = [];
221
+ retrievalFired = false;
222
+ eligibleRefs = [];
223
+ deliveryMode = 'disabled';
224
+ deliveredContentHash;
225
+ capturedAt = new Date().toISOString();
226
+ constructor(deps, meta) {
227
+ this.deps = deps;
228
+ this.meta = meta;
229
+ }
230
+ async firstTurnPickup(firstMessage) {
231
+ const config = parsePickupConfig(this.meta.pickup);
232
+ if (!config.enabled) {
233
+ return {
234
+ contextBlock: null,
235
+ packets: [],
236
+ searchedTerms: [],
237
+ retrievalFired: false,
238
+ eligibleRefs: [],
239
+ deliveredRefs: [],
240
+ deliveryMode: 'disabled',
241
+ };
242
+ }
243
+ const terms = deriveSearchTerms(firstMessage, this.meta.repositorySlug);
244
+ // Search with every term; score with the discriminating ones only — the
245
+ // repository name tags every record in an in-repo corpus, so counting it
246
+ // halved the effective relevance floor (#1886).
247
+ const scoringTerms = discriminatingTerms(terms, this.meta.repositorySlug);
248
+ this.searchedTerms = terms;
249
+ this.retrievalFired = true;
250
+ this.deliveryMode = 'delivered';
251
+ if (terms.length === 0) {
252
+ this.deliveryMode = 'withheld';
253
+ return {
254
+ contextBlock: null,
255
+ packets: [],
256
+ searchedTerms: terms,
257
+ retrievalFired: true,
258
+ eligibleRefs: [],
259
+ deliveredRefs: [],
260
+ deliveryMode: this.deliveryMode,
261
+ };
262
+ }
263
+ // Issue all per-term searches concurrently — the searches are
264
+ // independent (mono #1795: sequential awaits serialized ~1.6s/term of
265
+ // live indexer round-trips, blowing the 15s host deadline once lexical
266
+ // v2 widened the term budget to 10). `Promise.all` preserves result
267
+ // order by term index regardless of resolution order, so the merge
268
+ // below stays in term order — dedup priority and the first-observed
269
+ // degraded reason are byte-identical to the old sequential loop. A
270
+ // rejected promise would violate the PortResult convention (ports
271
+ // resolve, never throw), but is guarded anyway: it degrades that one
272
+ // term's contribution rather than the whole pickup (fail-open).
273
+ const results = await Promise.all(terms.map((term) => this.deps.corpus.search(term).catch((error) => degraded(`corpus search rejected: ${errorReason(error)}`))));
274
+ // Merge hits in term order (skip non-ok reads → fail open; keep the
275
+ // first degraded reason observed for an honest, non-crashing report).
276
+ let degradedReason;
277
+ const byRef = new Map();
278
+ for (const result of results) {
279
+ if (result.status !== 'ok' && degradedReason === undefined)
280
+ degradedReason = result.reason;
281
+ for (const hit of valueOr(result, [])) {
282
+ if (!byRef.has(hit.ref))
283
+ byRef.set(hit.ref, hit);
284
+ }
285
+ }
286
+ const candidates = rankKnowledgeCandidates([...byRef.values()], scoringTerms);
287
+ if (candidates.length === 0) {
288
+ this.deliveryMode = degradedReason === undefined ? 'withheld' : 'degraded';
289
+ return {
290
+ contextBlock: null,
291
+ packets: [],
292
+ searchedTerms: terms,
293
+ retrievalFired: true,
294
+ eligibleRefs: [],
295
+ deliveredRefs: [],
296
+ deliveryMode: this.deliveryMode,
297
+ ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
298
+ };
299
+ }
300
+ // Metadata score-1 candidates are plausible enough to inspect but not
301
+ // relevant enough to inject. Fetch only the deterministic top K and
302
+ // re-score the original normalized terms against concise authored
303
+ // content (synthesis + step titles). Gets run concurrently inside the
304
+ // same host deadline as search; Promise.all preserves candidate order.
305
+ // Reuse successful fetched records below so escalation never causes a
306
+ // duplicate corpus/cache read. Any individual failure stays below the
307
+ // floor and records an honest degraded reason (fail open).
308
+ const fetchedRefs = [];
309
+ const prefetchedByRef = new Map();
310
+ const directCandidates = candidates.filter((candidate) => candidate.score >= RELEVANCE_FLOOR);
311
+ const nearMisses = candidates
312
+ .filter((candidate) => candidate.score < RELEVANCE_FLOOR)
313
+ .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)}`));
317
+ }));
318
+ const promotedCandidates = [];
319
+ for (let index = 0; index < nearMisses.length; index += 1) {
320
+ const candidate = nearMisses[index];
321
+ const result = nearMissResults[index];
322
+ prefetchedByRef.set(candidate.hit.ref, result);
323
+ if (result.status !== 'ok') {
324
+ if (degradedReason === undefined)
325
+ degradedReason = result.reason;
326
+ continue;
327
+ }
328
+ const record = result.value;
329
+ if (record === null)
330
+ continue;
331
+ if (record.isSkillPayload === true)
332
+ continue;
333
+ if (record.retrievalVisible !== true)
334
+ continue;
335
+ const score = scoreKnowledgeRecord(candidate.hit, record, scoringTerms);
336
+ if (score >= RELEVANCE_FLOOR)
337
+ promotedCandidates.push({ hit: candidate.hit, score });
338
+ }
339
+ const ranked = rankScoredKnowledgeHits([
340
+ ...directCandidates,
341
+ ...promotedCandidates,
342
+ ]).map((candidate) => candidate.hit);
343
+ if (ranked.length === 0) {
344
+ this.fetchedRefs = fetchedRefs;
345
+ this.deliveryMode = degradedReason === undefined ? 'withheld' : 'degraded';
346
+ return {
347
+ contextBlock: null,
348
+ packets: [],
349
+ searchedTerms: terms,
350
+ retrievalFired: true,
351
+ eligibleRefs: [],
352
+ deliveredRefs: [],
353
+ deliveryMode: this.deliveryMode,
354
+ ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
355
+ };
356
+ }
357
+ // Fetch full content for ranked candidates and project packets, walking
358
+ // 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).
370
+ const packets = [];
371
+ for (const hit of ranked) {
372
+ if (packets.length >= MAX_SELECTED_PACKETS)
373
+ 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') {
380
+ if (degradedReason === undefined)
381
+ degradedReason = result.reason;
382
+ continue;
383
+ }
384
+ const record = result.value;
385
+ if (record === null)
386
+ continue;
387
+ if (record.isSkillPayload === true)
388
+ continue;
389
+ // Post-fetch content guard (#1824, W2): content is the truth, the
390
+ // search-hit's retrievalVisible was only a hint used to clear ranking.
391
+ // Fail-closed — undefined excludes, exactly like isSkillPayload's
392
+ // fail-open is the opposite case.
393
+ if (record.retrievalVisible !== true)
394
+ continue;
395
+ let packet;
396
+ try {
397
+ packet = projectKnowledgePacket(record);
398
+ }
399
+ catch (error) {
400
+ degradedReason ??= `packet projection failed for ${hit.ref}: ${errorReason(error)}`;
401
+ continue;
402
+ }
403
+ if (packet.excerpts.length === 0 && packet.synthesis === undefined)
404
+ continue;
405
+ packets.push(packet);
406
+ }
407
+ this.fetchedRefs = fetchedRefs;
408
+ this.providedRefs = packets.map((packet) => packet.ref);
409
+ this.eligibleRefs = [...this.providedRefs];
410
+ this.packets = packets;
411
+ if (packets.length === 0) {
412
+ this.deliveryMode = degradedReason === undefined ? 'withheld' : 'degraded';
413
+ return {
414
+ contextBlock: null,
415
+ packets: [],
416
+ searchedTerms: terms,
417
+ retrievalFired: true,
418
+ eligibleRefs: this.eligibleRefs,
419
+ deliveredRefs: [],
420
+ deliveryMode: this.deliveryMode,
421
+ ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
422
+ };
423
+ }
424
+ const contextBlock = renderKnowledgePackets(packets);
425
+ this.deliveryMode = degradedReason === undefined ? 'delivered' : 'degraded';
426
+ this.deliveredContentHash = `sha256:${createHash('sha256').update(contextBlock).digest('hex')}`;
427
+ return {
428
+ contextBlock,
429
+ packets,
430
+ searchedTerms: terms,
431
+ retrievalFired: true,
432
+ eligibleRefs: this.eligibleRefs,
433
+ deliveredRefs: this.providedRefs,
434
+ deliveryMode: this.deliveryMode,
435
+ deliveredContentHash: this.deliveredContentHash,
436
+ ...(degradedReason !== undefined ? { degraded: degradedReason } : {}),
437
+ };
438
+ }
439
+ noteUserTurn(content) {
440
+ this.pushTurn('user', content);
441
+ }
442
+ noteAssistantTurn(content) {
443
+ this.pushTurn('assistant', content);
444
+ }
445
+ pushTurn(role, content) {
446
+ // A turn is a zero-duration point event on the single unix-nano time base.
447
+ const nowNano = `${Date.now()}000000`;
448
+ this.trajectory.push({
449
+ spanId: randomUUID(),
450
+ parentSpanId: null,
451
+ kind: 'jinn.agent_turn',
452
+ name: 'turn',
453
+ startTimeUnixNano: nowNano,
454
+ endTimeUnixNano: nowNano,
455
+ attributes: { role, content },
456
+ redactedKeys: [],
457
+ });
458
+ }
459
+ noteToolCall(call) {
460
+ this.trajectory.push({ ...call, kind: 'jinn.tool_call', redactedKeys: call.redactedKeys ?? [] });
461
+ }
462
+ async end(outcome) {
463
+ const episode = EpisodeV1WriteSchema.parse({
464
+ schemaVersion: EPISODE_SCHEMA_VERSION,
465
+ episodeId: randomUUID(),
466
+ session: {
467
+ sessionId: this.meta.sessionId,
468
+ capturedAt: this.capturedAt,
469
+ kind: this.meta.kind ?? 'user',
470
+ ...(this.meta.parentSessionId ? { parentSessionId: this.meta.parentSessionId } : {}),
471
+ },
472
+ origin: {
473
+ writer: this.meta.harness.name,
474
+ build: this.meta.harness.version,
475
+ },
476
+ task: {
477
+ summary: this.meta.taskSummary,
478
+ distributionTags: this.meta.distributionTags ?? [],
479
+ ...(this.meta.repositorySlug ? { repositorySlug: this.meta.repositorySlug } : {}),
480
+ },
481
+ trajectory: this.trajectory,
482
+ environment: {
483
+ harness: this.meta.harness,
484
+ model: this.meta.model,
485
+ tools: this.meta.tools,
486
+ skillsLoadout: this.meta.skillsLoadout ?? [],
487
+ },
488
+ outcome: {
489
+ status: outcome.status,
490
+ verificationStrength: outcome.verifiabilityTier,
491
+ ...(outcome.summary !== undefined ? { summary: outcome.summary } : {}),
492
+ ...(outcome.acceptedDiff !== undefined ? { acceptedDiff: outcome.acceptedDiff } : {}),
493
+ ...(outcome.testRuns !== undefined ? { testRuns: outcome.testRuns } : {}),
494
+ },
495
+ cost: {
496
+ durationMs: outcome.durationMs,
497
+ ...(outcome.tokens ? { tokens: outcome.tokens } : {}),
498
+ },
499
+ retention: { policy: outcome.retentionPolicy },
500
+ provenance: 'contributed',
501
+ });
502
+ return completeSession(this.deps, {
503
+ contractVersion: JINN_PLUGIN_CONTRACT_VERSION,
504
+ episode,
505
+ activity: {
506
+ searchedTerms: this.searchedTerms,
507
+ providedRefs: this.providedRefs,
508
+ retrievalFired: this.retrievalFired,
509
+ eligibleRefs: this.eligibleRefs,
510
+ deliveredRefs: this.providedRefs,
511
+ deliveryMode: this.deliveryMode,
512
+ ...(this.deliveredContentHash
513
+ ? { deliveredContentHash: this.deliveredContentHash }
514
+ : {}),
515
+ surfacedRefs: [],
516
+ fetchedRefs: this.fetchedRefs,
517
+ installedSkillRefs: [],
518
+ },
519
+ eligibilityInputs: {
520
+ publicRepo: outcome.publicRepo,
521
+ acceptedDiff: outcome.acceptedDiff,
522
+ },
523
+ }, { providedPackets: this.packets });
524
+ }
525
+ }
526
+ /**
527
+ * Composes the block the host injects into the first user message, verbatim
528
+ * and cache-safe (rescope §3.4).
529
+ */
530
+ function renderKnowledgePacket(packet) {
531
+ const lines = [
532
+ `${packet.task.summary} · ${packet.outcome.status}/${packet.outcome.verifiabilityTier}`,
533
+ ];
534
+ if (packet.synthesis)
535
+ lines.push(packet.synthesis);
536
+ for (const excerpt of packet.excerpts)
537
+ lines.push(`- ${excerpt.label}: ${excerpt.text}`);
538
+ 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}`);
541
+ return lines.join('\n');
542
+ }
543
+ function renderKnowledgePackets(packets) {
544
+ return [
545
+ '[jinn corpus] Prior evidence relevant to this task:',
546
+ ...packets.map(renderKnowledgePacket),
547
+ ].join('\n');
548
+ }
549
+ export function createJinnPlugin(deps) {
550
+ return {
551
+ session(meta) {
552
+ return new PluginSession(deps, meta);
553
+ },
554
+ completeSession(input) {
555
+ return completeSession(deps, input);
556
+ },
557
+ history() {
558
+ return foldHistory(deps);
559
+ },
560
+ explain(sessionRef) {
561
+ return foldExplain(sessionRef, deps);
562
+ },
563
+ previewContribution(acknowledge = false) {
564
+ return previewContribution(deps, acknowledge);
565
+ },
566
+ async contributionLedger() {
567
+ try {
568
+ return await deps.contribution.ledger();
569
+ }
570
+ catch (error) {
571
+ return unavailable(`contribution ledger failed: ${errorReason(error)}`);
572
+ }
573
+ },
574
+ async disableContributionPublication() {
575
+ if (!deps.contribution.disableUnpublished) {
576
+ return unavailable('contribution disable is unavailable');
577
+ }
578
+ try {
579
+ return await deps.contribution.disableUnpublished();
580
+ }
581
+ catch (error) {
582
+ return unavailable(`contribution disable failed: ${errorReason(error)}`);
583
+ }
584
+ },
585
+ };
586
+ }
@@ -0,0 +1,53 @@
1
+ import type { PortResult } from '../outcome.js';
2
+ import type { ContributionCandidateV1 } from '../schemas/contribution-candidate.js';
3
+ export type ContributionLocalState = 'recorded' | 'minted' | 'rejected';
4
+ export type ContributionPublicationState = 'disabled' | 'preview-required' | 'queued' | 'published' | 'vetoed';
5
+ export type ContributionStatus = ContributionLocalState | Exclude<ContributionPublicationState, 'disabled'>;
6
+ export interface ContributionState {
7
+ localState: ContributionLocalState;
8
+ publicationState: ContributionPublicationState;
9
+ mintRef?: string;
10
+ publicationRef?: string;
11
+ }
12
+ export interface ContributionStatusSnapshot extends ContributionState {
13
+ status: ContributionStatus;
14
+ }
15
+ export interface ContributionLedgerEntry extends ContributionStatusSnapshot {
16
+ recordId: string;
17
+ sourceId: string;
18
+ /** Absent when a migrated reference no longer resolves to retained evidence. */
19
+ createdAt?: string;
20
+ verifiabilityTier?: 'user-accepted' | 'tests-passed';
21
+ /** Sanitized repository facts used by the local first-share preview. */
22
+ repositorySlug?: string;
23
+ baseCommit?: string;
24
+ }
25
+ export interface ContributionRecordOptions {
26
+ /** Persist a durable veto in the same atomic write that creates the record. */
27
+ publicationState?: 'vetoed';
28
+ }
29
+ /** Collapse the independent local and outbound axes into one display status. */
30
+ export declare function deriveContributionStatus(state: Pick<ContributionState, 'localState' | 'publicationState'>): ContributionStatus;
31
+ export interface ContributionPort {
32
+ /** Production verifies the canonical Episode, then stores only its reference and state. */
33
+ recordMineable(candidate: ContributionCandidateV1, options?: ContributionRecordOptions): Promise<PortResult<{
34
+ recordId: string;
35
+ }>>;
36
+ ledger(): Promise<PortResult<ContributionLedgerEntry[]>>;
37
+ /** Retained name for adapter compatibility; reports both axes and the derived status. */
38
+ mintStatus(recordId: string): Promise<PortResult<ContributionStatusSnapshot>>;
39
+ authorize(recordId: string): Promise<PortResult<{
40
+ recordId: string;
41
+ publicationState: 'queued';
42
+ status: 'queued';
43
+ }>>;
44
+ veto(recordId: string): Promise<PortResult<{
45
+ recordId: string;
46
+ publicationState: 'vetoed';
47
+ status: 'vetoed';
48
+ }>>;
49
+ /** Disable every unpublished standing authorization when sharing is turned off. */
50
+ disableUnpublished?(): Promise<PortResult<{
51
+ recordIds: string[];
52
+ }>>;
53
+ }
@@ -0,0 +1,14 @@
1
+ /** Collapse the independent local and outbound axes into one display status. */
2
+ export function deriveContributionStatus(state) {
3
+ if (state.publicationState === 'published')
4
+ return 'published';
5
+ if (state.publicationState === 'vetoed')
6
+ return 'vetoed';
7
+ if (state.localState === 'rejected')
8
+ return 'rejected';
9
+ if (state.publicationState === 'preview-required')
10
+ return 'preview-required';
11
+ if (state.publicationState === 'queued')
12
+ return 'queued';
13
+ return state.localState;
14
+ }