@xandreed/core 0.13.0 → 0.14.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 (148) hide show
  1. package/README.md +1 -1
  2. package/dist/conformance.functions.d.ts +34 -0
  3. package/dist/conformance.functions.js +25 -0
  4. package/dist/domain/bash.entity.d.ts +82 -0
  5. package/dist/domain/bash.entity.functions.d.ts +17 -0
  6. package/dist/domain/bash.entity.functions.js +26 -0
  7. package/dist/domain/bash.entity.functions.test.d.ts +1 -0
  8. package/dist/domain/bash.entity.js +30 -0
  9. package/dist/domain/file-system.entity.d.ts +91 -0
  10. package/dist/domain/file-system.entity.functions.d.ts +18 -0
  11. package/dist/domain/file-system.entity.functions.js +30 -0
  12. package/dist/domain/file-system.entity.js +37 -0
  13. package/dist/domain/loop-event.entity.d.ts +9 -0
  14. package/dist/domain/media.entity.d.ts +79 -0
  15. package/dist/domain/media.entity.functions.d.ts +76 -0
  16. package/dist/domain/media.entity.functions.js +110 -0
  17. package/dist/domain/media.entity.js +38 -0
  18. package/dist/domain/media.entity.test.d.ts +1 -0
  19. package/dist/domain/message.entity.d.ts +157 -5
  20. package/dist/domain/message.entity.js +8 -4
  21. package/dist/domain/model-catalog.entity.d.ts +2 -0
  22. package/dist/domain/model-catalog.entity.js +2 -1
  23. package/dist/domain/token-usage.entity.d.ts +45 -1
  24. package/dist/domain/token-usage.entity.functions.d.ts +30 -1
  25. package/dist/domain/token-usage.entity.functions.js +24 -4
  26. package/dist/domain/token-usage.entity.js +19 -2
  27. package/dist/domain/token-usage.entity.test.d.ts +1 -0
  28. package/dist/harness/capability.entity.functions.js +2 -1
  29. package/dist/harness/plugin.adapter.d.ts +3 -0
  30. package/dist/harness/plugin.adapter.js +7 -1
  31. package/dist/harness/skill-files.entity.d.ts +101 -0
  32. package/dist/harness/skill-files.entity.functions.d.ts +58 -0
  33. package/dist/harness/skill-files.entity.functions.js +150 -0
  34. package/dist/harness/skill-files.entity.functions.test.d.ts +1 -0
  35. package/dist/harness/skill-files.entity.js +25 -0
  36. package/dist/index.d.ts +48 -8
  37. package/dist/index.js +67 -6
  38. package/dist/loop/mapping.d.ts +12 -6
  39. package/dist/loop/mapping.js +30 -6
  40. package/dist/loop/stepContext.d.ts +8 -0
  41. package/dist/loop/stepContext.js +1 -1
  42. package/dist/loop/traceContent.d.ts +29 -0
  43. package/dist/loop/traceContent.js +4 -0
  44. package/dist/loop/traceContent.test.d.ts +1 -0
  45. package/dist/memory/memory-log.entity.d.ts +152 -14
  46. package/dist/memory/memory-log.entity.functions.d.ts +35 -5
  47. package/dist/memory/memory-log.entity.functions.js +59 -20
  48. package/dist/memory/memory-log.entity.js +19 -6
  49. package/dist/memory/memory-session.js +10 -6
  50. package/dist/memory/memory.conformance.d.ts +8 -1
  51. package/dist/memory/memory.conformance.js +32 -0
  52. package/dist/models/auth-store.conformance.d.ts +13 -0
  53. package/dist/models/auth-store.conformance.js +73 -0
  54. package/dist/models/auth-store.conformance.test.d.ts +1 -0
  55. package/dist/models/model-catalog.conformance.d.ts +15 -0
  56. package/dist/models/model-catalog.conformance.js +27 -0
  57. package/dist/models/model-catalog.conformance.test.d.ts +1 -0
  58. package/dist/models/settings-store.conformance.d.ts +26 -0
  59. package/dist/models/settings-store.conformance.js +80 -0
  60. package/dist/models/settings-store.conformance.test.d.ts +1 -0
  61. package/dist/ports/activity.port.d.ts +35 -0
  62. package/dist/ports/activity.port.js +7 -0
  63. package/dist/ports/auth-store.port.d.ts +7 -5
  64. package/dist/ports/bash.port.d.ts +22 -0
  65. package/dist/ports/bash.port.js +4 -0
  66. package/dist/ports/capability.port.d.ts +11 -1
  67. package/dist/ports/file-system.port.d.ts +44 -15
  68. package/dist/ports/file-system.port.js +1 -7
  69. package/dist/ports/memory.port.d.ts +13 -1
  70. package/dist/ports/model-catalog.port.d.ts +4 -1
  71. package/dist/ports/run-context.port.d.ts +6 -1
  72. package/dist/ports/session-log.port.d.ts +4 -1
  73. package/dist/ports/sessions.port.d.ts +35 -6
  74. package/dist/ports/settings-store.port.d.ts +11 -7
  75. package/dist/ports/step-loop.port.d.ts +30 -2
  76. package/dist/ports/tasks.port.d.ts +5 -1
  77. package/dist/ports/turn-admission.port.d.ts +8 -2
  78. package/dist/ports/turn-events.port.d.ts +8 -1
  79. package/dist/ports/turn-scope.port.d.ts +19 -1
  80. package/dist/ports/turn.port.d.ts +70 -5
  81. package/dist/ports/utility-llm.port.d.ts +2 -0
  82. package/dist/session/activity.entity.d.ts +42 -0
  83. package/dist/session/activity.entity.functions.d.ts +21 -0
  84. package/dist/session/activity.entity.functions.js +23 -0
  85. package/dist/session/activity.entity.functions.test.d.ts +1 -0
  86. package/dist/session/activity.entity.js +15 -0
  87. package/dist/session/chassis.js +2 -1
  88. package/dist/session/session-event.entity.d.ts +68 -6
  89. package/dist/session/session-event.entity.functions.d.ts +34 -2
  90. package/dist/session/session-event.entity.functions.js +54 -16
  91. package/dist/session/session-event.entity.js +19 -2
  92. package/dist/session/session-log.conformance.d.ts +4 -3
  93. package/dist/session/session-log.conformance.js +16 -14
  94. package/dist/session/session-log.recovery.conformance.d.ts +40 -0
  95. package/dist/session/session-log.recovery.conformance.js +143 -0
  96. package/dist/session/session-log.recovery.conformance.test.d.ts +1 -0
  97. package/dist/session/sessions.conformance.d.ts +79 -0
  98. package/dist/session/sessions.conformance.js +355 -0
  99. package/dist/session/sessions.entity.d.ts +36 -3
  100. package/dist/session/sessions.entity.functions.d.ts +42 -0
  101. package/dist/session/sessions.entity.functions.js +26 -0
  102. package/dist/session/sessions.entity.js +17 -4
  103. package/dist/session/sessions.entity.test.d.ts +1 -0
  104. package/dist/session/tasks.conformance.d.ts +70 -0
  105. package/dist/session/tasks.conformance.js +213 -0
  106. package/dist/session/tasks.conformance.test.d.ts +1 -0
  107. package/dist/turn/model-request.entity.d.ts +4 -0
  108. package/dist/turn/model-request.entity.js +7 -2
  109. package/dist/turn/step-loop.conformance.d.ts +4 -1
  110. package/dist/turn/step-loop.conformance.js +73 -7
  111. package/dist/turn/tool-registry.conformance.d.ts +38 -0
  112. package/dist/turn/tool-registry.conformance.js +206 -0
  113. package/dist/turn/turn-bus.js +4 -3
  114. package/dist/turn/turn-event.entity.d.ts +134 -34
  115. package/dist/turn/turn-event.entity.functions.d.ts +11 -0
  116. package/dist/turn/turn-event.entity.functions.js +9 -1
  117. package/dist/turn/turn-event.entity.js +76 -3
  118. package/dist/turn/turn-event.entity.test.d.ts +1 -0
  119. package/dist/turn/turn-lifecycle.d.ts +46 -8
  120. package/dist/turn/turn-lifecycle.js +33 -3
  121. package/dist/turn/turn-lifecycle.test.d.ts +1 -0
  122. package/dist/turn/turn-run.d.ts +15 -3
  123. package/dist/turn/turn-run.js +37 -9
  124. package/dist/turn/turn.adapter.d.ts +6 -5
  125. package/dist/turn/turn.adapter.js +15 -3
  126. package/dist/turn/turn.conformance.d.ts +11 -2
  127. package/dist/turn/turn.conformance.js +63 -2
  128. package/dist/turn/user-message.entity.d.ts +14 -4
  129. package/dist/turn/user-message.entity.functions.d.ts +21 -0
  130. package/dist/turn/user-message.entity.functions.js +24 -0
  131. package/dist/turn/user-message.entity.js +6 -8
  132. package/dist/turn/user-message.entity.test.d.ts +1 -0
  133. package/dist/util/glob.d.ts +30 -0
  134. package/dist/util/glob.js +91 -0
  135. package/dist/util/glob.test.d.ts +1 -0
  136. package/dist/workspace/bash.conformance.d.ts +37 -0
  137. package/dist/workspace/bash.conformance.js +120 -0
  138. package/dist/workspace/bash.conformance.test.d.ts +1 -0
  139. package/dist/workspace/file-system.conformance.d.ts +38 -0
  140. package/dist/workspace/file-system.conformance.js +254 -0
  141. package/dist/workspace/file-system.conformance.test.d.ts +1 -0
  142. package/dist/workspace/file-system.memory.adapter.d.ts +23 -0
  143. package/dist/workspace/file-system.memory.adapter.js +132 -0
  144. package/package.json +1 -1
  145. package/dist/harness/skill-files.d.ts +0 -17
  146. package/dist/harness/skill-files.js +0 -25
  147. package/dist/ports/shell.port.d.ts +0 -32
  148. package/dist/ports/shell.port.js +0 -16
@@ -1,5 +1,8 @@
1
1
  import { Effect, Option, Schema } from "effect";
2
+ import { elideMediaData, IMAGE_TOKEN_ESTIMATE, mediaAt, mediaLabel, mediaSlotsOf } from "../domain/media.entity.functions.js";
3
+ import { MEDIA_LIMITS } from "../domain/media.entity.js";
2
4
  import { handoffToMessage } from "../loop/mapping.js";
5
+ import { attachmentsOf, userMessageText } from "../turn/user-message.entity.functions.js";
3
6
  import { EntryId as EntryIdSchema } from "./memory-log.entity.js";
4
7
  export const canonicalJson = (value) => JSON.stringify(sortKeys(value)) ?? "null";
5
8
  const sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : typeof value === "object" && value !== null ? Object.fromEntries(Object.keys(value).sort().flatMap((key) => {
@@ -8,11 +11,15 @@ const sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : typeof
8
11
  })) : value;
9
12
  export const fingerprintOf = (text) => {
10
13
  return `${Array.from(text).reduce((h, ch) => Math.imul((h ^ ch.charCodeAt(0)) >>> 0, 16777619) >>> 0, 2166136261).toString(16).padStart(8, "0")}${text.length.toString(16)}`;
11
- }, estimateTokens = (text) => Math.ceil(text.length / 3), estimateMessageTokens = (messages) => messages.reduce((sum, message) => sum + estimateTokens(canonicalJson(message)), 0), entryId = (runId, index) => EntryIdSchema.make(`${runId}:${index}`);
14
+ }, estimateTokens = (text) => Math.ceil(text.length / 3), estimateMessageTokens = (messages) => messages.reduce((sum, message) => sum + estimateTokens(canonicalJson(elideMediaData(message))) + mediaSlotsOf(message).length * IMAGE_TOKEN_ESTIMATE, 0), entryId = (runId, index) => EntryIdSchema.make(`${runId}:${index}`);
12
15
  const digestsOf = (entries) => new Map(entries.flatMap((entry) => entry.body._tag === "ToolDigest" ? [[String(entry.body.entry), entry.body.text]] : [])), appliedCompactions = (entries, strategy) => entries.reduce((applied, entry) => {
13
- if (entry.body._tag !== "Compaction" || entry.body.strategy !== strategy)
16
+ if (entry.body._tag !== "Compaction")
14
17
  return applied;
15
18
  const action = entry.body.action, ids = [...applied.ids, entry.id];
19
+ if (action._tag === "OmitMedia")
20
+ return { ...applied, ids, omitted: new Set([...applied.omitted, ...action.entries.map(String)]) };
21
+ if (entry.body.strategy !== strategy)
22
+ return applied;
16
23
  if (action._tag === "CompactViews")
17
24
  return { ...applied, ids, views: new Map([...applied.views, ...action.entries.map((id, index) => [id, action.texts[index] ?? ""])]) };
18
25
  if (action._tag === "Spill")
@@ -32,7 +39,7 @@ ${action.ledger}` })
32
39
  cutTurn: action.keepFromTurn - 1,
33
40
  preamble: Option.some(handoffToMessage(action.summary))
34
41
  };
35
- }, { views: new Map, cutTurn: 0, preamble: Option.none(), ids: [] }), toolCallIds = (message) => message.role === "assistant" ? message.content.flatMap((part) => part.type === "tool-call" ? [part.toolCallId] : []) : [], assistantText = (message) => {
42
+ }, { views: new Map, cutTurn: 0, preamble: Option.none(), ids: [], omitted: new Set }), toolCallIds = (message) => message.role === "assistant" ? message.content.flatMap((part) => part.type === "tool-call" ? [part.toolCallId] : []) : [], assistantText = (message) => {
36
43
  if (message.role !== "assistant")
37
44
  return Option.none();
38
45
  const text = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
@@ -53,12 +60,47 @@ export const artifactLines = (artifacts, maxImages) => {
53
60
  ].join(`
54
61
  `);
55
62
  };
56
- const userRoleMessage = (content) => ({ role: "user", content }), assistantMessage = (text) => ({ role: "assistant", content: [{ type: "text", text }] });
63
+ const userRoleMessage = (content) => ({ role: "user", content }), assistantMessage = (text) => ({ role: "assistant", content: [{ type: "text", text }] }), imageKey = (entry, index) => `${entry.id}#${index}`, noneChosen = { chosen: new Set, images: 0, chars: 0, full: !1 }, charsOf = (images) => images.reduce((sum, image) => sum + image.media.data.length, 0), mediaPlan = (entries, options) => {
64
+ const applied = appliedCompactions(entries, options.strategy), digests = options.digests ? digestsOf(entries) : new Map, kept = (entry) => entry.turn > applied.cutTurn, imagesOf = (entry) => {
65
+ const body = entry.body;
66
+ if (!kept(entry) || applied.omitted.has(entry.id))
67
+ return [];
68
+ if (body._tag === "TurnStarted")
69
+ return attachmentsOf(body.userMessage).map((media, index) => ({ key: imageKey(entry, index), media }));
70
+ if (body._tag !== "ToolResult" || body.isError || applied.views.has(entry.id) || digests.has(entry.id))
71
+ return [];
72
+ return (body.media ?? []).flatMap((slot, index) => Option.toArray(Option.map(mediaAt(body.encoded, slot), (media) => ({ key: imageKey(entry, index), media }))));
73
+ }, maxImages = Math.min(options.media.maxImages, MEDIA_LIMITS.requestImages), take = (choice, group) => choice.full ? choice : choice.images + group.length <= maxImages && choice.chars + charsOf(group) <= options.media.maxChars ? {
74
+ ...choice,
75
+ chosen: new Set([...choice.chosen, ...group.map((image) => image.key)]),
76
+ images: choice.images + group.length,
77
+ chars: choice.chars + charsOf(group)
78
+ } : { ...choice, full: !0 }, current = entries.filter((entry) => entry.turn === options.currentTurn), attachments = current.flatMap((entry) => entry.body._tag === "TurnStarted" ? imagesOf(entry).map((image) => [image]) : []), results = current.flatMap((entry) => entry.body._tag === "ToolResult" ? [imagesOf(entry)] : []).filter((group) => group.length > 0).reverse(), older = options.media.mode === "none" ? [] : entries.flatMap((entry) => entry.turn < options.currentTurn ? imagesOf(entry).map((image) => ({ turn: entry.turn, image })) : []), earlier = [...new Set(older.map((item) => item.turn))].sort((left, right) => right - left).map((turn) => older.filter((item) => item.turn === turn).map((item) => item.image)), inline = [...attachments, ...results, ...earlier].reduce(take, noneChosen).chosen;
79
+ return { applied, digests, kept, imagesOf, inline };
80
+ }, userMessageContent = (entry, message, inline) => {
81
+ const images = attachmentsOf(message), sent = images.flatMap((media, index) => inline.has(imageKey(entry, index)) ? [media] : []);
82
+ if (sent.length === 0)
83
+ return userRoleMessage(userMessageText(message));
84
+ const text = [message.text, ...images.flatMap((media, index) => inline.has(imageKey(entry, index)) ? [] : [mediaLabel(media)])].filter((line) => line.length > 0).join(`
85
+ `);
86
+ return { role: "user", content: [...sent, ...text.length === 0 ? [] : [{ type: "text", text }]] };
87
+ }, toolResultPart = (entry, body, text, options, inline) => {
88
+ const images = (body.media ?? []).map((slot, index) => ({ key: imageKey(entry, index), slot, media: mediaAt(body.encoded, slot) })), sent = images.flatMap((image) => inline.has(image.key) ? Option.toArray(image.media) : []), labels = [...(body.mediaNotes ?? []).filter((line) => !text.includes(line)), ...images.flatMap((image) => inline.has(image.key) ? [] : [mediaLabel(Option.getOrElse(image.media, () => image.slot))])], shown = labels.length === 0 ? text : [text, ...labels].filter((line) => line.length > 0).join(`
89
+ `);
90
+ return {
91
+ type: "tool-result",
92
+ toolCallId: body.toolCallId,
93
+ toolName: body.toolName,
94
+ output: `${shown}${artifactLines(body.artifacts, options.media.maxImages)}`,
95
+ isError: body.isError,
96
+ ...sent.length === 0 ? {} : { media: sent }
97
+ };
98
+ };
57
99
  export const renderLog = (entries, options) => {
58
- const applied = appliedCompactions(entries, options.strategy), digests = options.digests ? digestsOf(entries) : new Map, pinned = new Set(entries.flatMap((entry) => entry.body._tag === "ToolResult" && entry.body.pinned ? [String(entry.body.toolCallId)] : [])), kept = (entry) => entry.turn > applied.cutTurn, stepContext = options.stepContext === "none" ? [] : entries.filter((entry) => entry.body._tag === "StepContext" && entry.runId === options.currentRun).slice(-1), initial = { out: [], lastText: new Map }, body = entries.reduce((state, entry) => {
100
+ const { applied, digests, kept, inline } = mediaPlan(entries, options), pinned = new Set(entries.flatMap((entry) => entry.body._tag === "ToolResult" && entry.body.pinned ? [String(entry.body.toolCallId)] : [])), stepContext = options.stepContext === "none" ? [] : entries.filter((entry) => entry.body._tag === "StepContext" && entry.runId === options.currentRun).slice(-1), initial = { out: [], lastText: new Map }, body = entries.reduce((state, entry) => {
59
101
  const b = entry.body;
60
102
  if (b._tag === "TurnStarted")
61
- return kept(entry) ? { ...state, out: [...state.out, userRoleMessage(b.userMessage.text)] } : state;
103
+ return kept(entry) ? { ...state, out: [...state.out, userMessageContent(entry, b.userMessage, inline)] } : state;
62
104
  if (b._tag === "TurnContext")
63
105
  return kept(entry) && (options.turnContext === "all" || entry.turn === options.currentTurn) ? { ...state, out: [...state.out, userRoleMessage(b.text)] } : state;
64
106
  if (b._tag === "Message") {
@@ -74,13 +116,7 @@ export const renderLog = (entries, options) => {
74
116
  if (b._tag === "ToolResult") {
75
117
  if (!kept(entry) && !b.pinned)
76
118
  return state;
77
- const text = applied.views.get(entry.id) ?? digests.get(entry.id) ?? b.view, message = { role: "tool", content: [{
78
- type: "tool-result",
79
- toolCallId: b.toolCallId,
80
- toolName: b.toolName,
81
- output: `${text}${artifactLines(b.artifacts, options.media.maxImages)}`,
82
- isError: b.isError
83
- }] };
119
+ const text = applied.views.get(entry.id) ?? digests.get(entry.id) ?? b.view, message = { role: "tool", content: [toolResultPart(entry, b, text, options, inline)] };
84
120
  return { ...state, out: [...state.out, message] };
85
121
  }
86
122
  if (b._tag === "TurnEnded" && options.replies && kept(entry))
@@ -91,11 +127,14 @@ export const renderLog = (entries, options) => {
91
127
  return state;
92
128
  }, initial).out, tail = stepContext.flatMap((entry) => entry.body._tag === "StepContext" ? [userRoleMessage(entry.body.text)] : []);
93
129
  return mergeToolMessages([...Option.toArray(applied.preamble), ...body, ...tail]);
130
+ }, entriesSendingMedia = (entries, options) => {
131
+ const { imagesOf, inline } = mediaPlan(entries, options);
132
+ return entries.flatMap((entry) => imagesOf(entry).some((image) => inline.has(image.key)) ? [entry.id] : []);
94
133
  }, buildContext = (entries, options) => {
95
134
  const messages = renderLog(entries, options);
96
135
  return {
97
136
  messages,
98
- fingerprint: fingerprintOf(canonicalJson(messages)),
137
+ fingerprint: fingerprintOf(canonicalJson(messages.map(elideMediaData))),
99
138
  estimatedTokens: estimateMessageTokens(messages),
100
139
  compactions: appliedCompactions(entries, options.strategy).ids,
101
140
  through: Option.fromNullishOr(entries.at(-1)?.id)
@@ -108,17 +147,17 @@ export const renderLog = (entries, options) => {
108
147
  tools: [...active.tools, ...entry.body.tools.filter((tool) => !active.tools.includes(tool))]
109
148
  }, { skills: [], tools: [] }), referenceTranscript = (entries) => entries.flatMap((entry) => {
110
149
  if (entry.body._tag === "TurnStarted")
111
- return [userRoleMessage(entry.body.userMessage.text)];
150
+ return [userRoleMessage(userMessageText(entry.body.userMessage))];
112
151
  if (entry.body._tag === "TurnEnded")
113
152
  return Option.match(entry.body.reply, { onNone: () => [], onSome: (text) => text.length === 0 ? [] : [{ role: "assistant", content: [{ type: "text", text }] }] });
114
153
  return [];
115
154
  }), rawTranscript = (entries) => mergeToolMessages(entries.flatMap((entry) => {
116
155
  if (entry.body._tag === "TurnStarted")
117
- return [userRoleMessage(entry.body.userMessage.text)];
156
+ return [userRoleMessage(userMessageText(entry.body.userMessage))];
118
157
  if (entry.body._tag === "Message")
119
158
  return [entry.body.message];
120
159
  if (entry.body._tag === "ToolResult")
121
- return [{ role: "tool", content: [{ type: "tool-result", toolCallId: entry.body.toolCallId, toolName: entry.body.toolName, output: entry.body.encoded, isError: entry.body.isError }] }];
160
+ return [{ role: "tool", content: [{ type: "tool-result", toolCallId: entry.body.toolCallId, toolName: entry.body.toolName, output: elideMediaData(entry.body.encoded), isError: entry.body.isError }] }];
122
161
  return [];
123
162
  })), emptyQuery = {
124
163
  kinds: [],
@@ -131,7 +170,7 @@ export const renderLog = (entries, options) => {
131
170
  const matches = entries.filter((entry) => (query.kinds.length === 0 || query.kinds.includes(entry.body._tag)) && Option.match(query.turns, { onNone: () => !0, onSome: (range) => entry.turn >= range.from && entry.turn <= range.to }) && Option.match(query.tool, { onNone: () => !0, onSome: (tool) => entry.body._tag === "ToolResult" && entry.body.toolName === tool }) && Option.match(query.subject, {
132
171
  onNone: () => !0,
133
172
  onSome: (wanted) => entry.body._tag === "ToolResult" && entry.body.subjects.some((subject) => subject.kind === wanted.kind && Option.match(wanted.id, { onNone: () => !0, onSome: (id) => subject.id === id }))
134
- }) && Option.match(query.text, { onNone: () => !0, onSome: (text) => canonicalJson(entry.body).toLowerCase().includes(text.toLowerCase()) }));
173
+ }) && Option.match(query.text, { onNone: () => !0, onSome: (text) => canonicalJson(elideMediaData(entry.body)).toLowerCase().includes(text.toLowerCase()) }));
135
174
  return Option.match(query.limit, { onNone: () => matches, onSome: (limit) => matches.slice(-limit) });
136
175
  }, toolInputsOf = (entries, tail) => new Map([
137
176
  ...entries.flatMap((entry) => entry.body._tag === "Message" ? [entry.body.message] : []),
@@ -149,7 +188,7 @@ export const digestTranscript = (entries, caps) => {
149
188
  const lines = entries.flatMap((entry) => {
150
189
  const body = entry.body;
151
190
  if (body._tag === "TurnStarted")
152
- return [`User: ${body.userMessage.text}`];
191
+ return [`User: ${userMessageText(body.userMessage)}`];
153
192
  if (body._tag === "Message")
154
193
  return Option.toArray(Option.map(assistantText(body.message), (text) => `Assistant: ${text}`));
155
194
  if (body._tag === "ToolResult")
@@ -168,7 +207,7 @@ ${lines.slice(1).join(`
168
207
  `).slice(-(caps.total - head.length))}`;
169
208
  }, ledgerOf = (entries, throughTurn, perTurnChars) => {
170
209
  const kept = entries.filter((entry) => entry.turn <= throughTurn), lines = [...new Set(kept.map((entry) => entry.turn))].sort((left, right) => left - right).flatMap((turn) => {
171
- const inTurn = kept.filter((entry) => entry.turn === turn), asked = inTurn.flatMap((entry) => entry.body._tag === "TurnStarted" ? [entry.body.userMessage.text] : []).at(0), reply = inTurn.flatMap((entry) => entry.body._tag === "TurnEnded" ? Option.toArray(entry.body.reply) : []).at(-1);
210
+ const inTurn = kept.filter((entry) => entry.turn === turn), asked = inTurn.flatMap((entry) => entry.body._tag === "TurnStarted" ? [userMessageText(entry.body.userMessage)] : []).at(0), reply = inTurn.flatMap((entry) => entry.body._tag === "TurnEnded" ? Option.toArray(entry.body.reply) : []).at(-1);
172
211
  return asked === void 0 ? [] : [`- Asked: ${clipText(asked, perTurnChars)}${reply === void 0 ? "" : ` \u2014 replied: ${clipText(reply, perTurnChars)}`}`];
173
212
  }), subjects = subjectsOf(kept, []).slice(-24).map((subject) => `${subject.kind} ${subject.id}${Option.match(subject.label, { onNone: () => "", onSome: (label) => ` (${label})` })}`);
174
213
  return [
@@ -1,7 +1,8 @@
1
- import { Schema } from "effect";
1
+ import { Schema, SchemaTransformation } from "effect";
2
+ import { Media, MediaSlot } from "../domain/media.entity.js";
2
3
  import { AgentMessage, ToolCallId } from "../domain/message.entity.js";
3
4
  import { DecisionId } from "../decision-record.entity.js";
4
- import { UserMessageFromString } from "../turn/user-message.entity.js";
5
+ import { UserMessage } from "../turn/user-message.entity.js";
5
6
  export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.brand("EntryId")), Subject = Schema.Struct({
6
7
  kind: Schema.Trimmed.check(Schema.isNonEmpty()),
7
8
  id: Schema.Trimmed.check(Schema.isNonEmpty()),
@@ -17,14 +18,24 @@ export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.bra
17
18
  Schema.TaggedStruct("CompactViews", { entries: Schema.Array(EntryId), texts: Schema.Array(Schema.String) }),
18
19
  Schema.TaggedStruct("Spill", { entry: EntryId, preview: Schema.String }),
19
20
  Schema.TaggedStruct("DropTurns", { throughTurn: Schema.Int, ledger: Schema.String }),
20
- Schema.TaggedStruct("Summarize", { keepFromTurn: Schema.Int, summary: Schema.String })
21
- ]), ActivationSource = Schema.Literals(["always", "matcher", "load_skill", "host"]), LogBody = Schema.Union([
21
+ Schema.TaggedStruct("Summarize", { keepFromTurn: Schema.Int, summary: Schema.String }),
22
+ Schema.TaggedStruct("OmitMedia", { entries: Schema.Array(EntryId), reason: Schema.String })
23
+ ]), ActivationSource = Schema.Literals(["always", "matcher", "load_skill", "host"]);
24
+ const StoredTurnStarted = Schema.Struct({
25
+ _tag: Schema.Literal("TurnStarted"),
26
+ prompt: Schema.String,
27
+ attachments: Schema.optionalKey(Schema.Array(Schema.toEncoded(Media)))
28
+ });
29
+ export const LogBody = Schema.Union([
22
30
  Schema.TaggedStruct("SystemPrepared", {
23
31
  fingerprint: Schema.String,
24
32
  text: Schema.String,
25
33
  sections: Schema.Array(Schema.Struct({ id: Schema.String, version: Schema.String, fingerprint: Schema.String }))
26
34
  }),
27
- Schema.TaggedStruct("TurnStarted", { userMessage: UserMessageFromString }).pipe(Schema.encodeKeys({ userMessage: "prompt" })),
35
+ StoredTurnStarted.pipe(Schema.decodeTo(Schema.TaggedStruct("TurnStarted", { userMessage: UserMessage }), SchemaTransformation.transform({
36
+ decode: ({ prompt, attachments }) => ({ _tag: "TurnStarted", userMessage: attachments === void 0 ? { text: prompt } : { text: prompt, attachments } }),
37
+ encode: ({ userMessage }) => userMessage.attachments === void 0 ? { _tag: "TurnStarted", prompt: userMessage.text } : { _tag: "TurnStarted", prompt: userMessage.text, attachments: userMessage.attachments }
38
+ }))),
28
39
  Schema.TaggedStruct("TurnContext", { sectionId: Schema.String, version: Schema.String, text: Schema.String }),
29
40
  Schema.TaggedStruct("Message", { message: AgentMessage }),
30
41
  Schema.TaggedStruct("ToolResult", {
@@ -36,7 +47,9 @@ export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.bra
36
47
  viewVersion: Schema.String,
37
48
  subjects: Schema.Array(Subject),
38
49
  artifacts: Schema.Array(ArtifactRef),
39
- pinned: Schema.Boolean
50
+ pinned: Schema.Boolean,
51
+ media: Schema.optionalKey(Schema.Array(MediaSlot)),
52
+ mediaNotes: Schema.optionalKey(Schema.Array(Schema.String))
40
53
  }),
41
54
  Schema.TaggedStruct("ToolDigest", {
42
55
  entry: EntryId,
@@ -1,4 +1,5 @@
1
1
  import { Clock, Effect, Option, Ref, Semaphore } from "effect";
2
+ import { toolResultMedia } from "../domain/media.entity.functions.js";
2
3
  import { ResultDigester } from "../ports/memory.port.js";
3
4
  import {
4
5
  buildContext,
@@ -55,19 +56,22 @@ export const openLogSession = (log, policy, scope) => Effect.gen(function* () {
55
56
  })
56
57
  }))), recordTail = (tail, views, step) => Effect.gen(function* () {
57
58
  const inputs = toolInputsOf(yield* Ref.get(entries), tail), bodies = yield* Effect.forEach(tail, (message) => message.role !== "tool" ? Effect.succeed([{ _tag: "Message", message }]) : Effect.forEach(message.content, (part) => {
58
- const params = inputs.get(String(part.toolCallId)) ?? {};
59
- return views.view(part.toolName, part.output, params, part.isError ?? !1).pipe(Effect.map((view) => ({
59
+ const params = inputs.get(String(part.toolCallId)) ?? {}, isError = part.isError ?? !1;
60
+ return views.view(part.toolName, part.output, params, isError).pipe(Effect.flatMap((view) => toolResultMedia(part.output, isError ? [] : view.media ?? []).pipe(Effect.map((media) => ({
60
61
  _tag: "ToolResult",
61
62
  toolCallId: part.toolCallId,
62
63
  toolName: part.toolName,
63
- isError: part.isError ?? !1,
64
+ isError,
64
65
  encoded: part.output,
65
- view: view.text,
66
+ view: media.labels.length === 0 ? view.text : [view.text, ...media.labels].filter((line) => line.length > 0).join(`
67
+ `),
66
68
  viewVersion: view.version,
67
69
  subjects: view.subjects,
68
70
  artifacts: view.artifacts,
69
- pinned: view.pinned
70
- })));
71
+ pinned: view.pinned,
72
+ ...media.slots.length === 0 ? {} : { media: media.slots },
73
+ ...media.labels.length === 0 ? {} : { mediaNotes: media.labels }
74
+ })))));
71
75
  })), recorded = yield* record(bodies.flat(), step), onWrite = policy.digestOnWrite, targets = Option.isNone(onWrite) ? [] : recorded.filter((entry) => entry.body._tag === "ToolResult" && onWrite.value({ tool: entry.body.toolName, chars: entry.body.view.length })), digests = yield* digest(targets, views, "write", step);
72
76
  return [...recorded, ...digests];
73
77
  }), maintain = (signal) => Effect.gen(function* () {
@@ -24,7 +24,14 @@ type Memory = Context.Service.Shape<typeof ConversationMemory>;
24
24
  * The ConversationMemory contract, as checks any strategy must pass: own
25
25
  * entry ids, a log that reads back exactly after a reopen, views and
26
26
  * artifacts kept, a pure build, the step context only when asked,
27
- * append-only maintenance and a reference transcript without tool traffic.
27
+ * append-only maintenance, a reference transcript without tool traffic, and
28
+ * a message's images kept through a reopen and built (never with an empty
29
+ * text part): the current turn's as image parts, an earlier turn's as image
30
+ * parts or label lines; a tool result's images stored once, kept through a
31
+ * reopen, and the current turn's built as the result's images. Those checks
32
+ * need a media budget that admits one small image, as the memory plugins'
33
+ * defaults do: a strategy set to send none (`maxImages: 0` or
34
+ * `maxChars: 0`) fails them, as it fails `turnConformance`'s.
28
35
  * `services` are the turn services the strategy needs (a summarizer…); each
29
36
  * session is opened with them provided.
30
37
  */
@@ -1,5 +1,7 @@
1
1
  import { Context, Effect, Option, Ref, Schema } from "effect";
2
2
  import { ConformanceFailure } from "../conformance.entity.js";
3
+ import { pixelPng } from "../conformance.functions.js";
4
+ import { mediaFromBase64, mediaLabel, mediaSlotsOf } from "../domain/media.entity.functions.js";
3
5
  import { ConversationId, ToolCallId } from "../domain/message.entity.js";
4
6
  import { HarnessError } from "../harness/plugin.entity.js";
5
7
  import { draftOfEntry, entriesOfEvents, turnStartedDraft } from "../session/session-event.entity.functions.js";
@@ -92,6 +94,36 @@ export const memoryConformance = (memory, services = Context.empty()) => {
92
94
  const after = yield* session.entries;
93
95
  yield* expect("append-only", encodeEntries(after.slice(0, before.length)) === encodeEntries(before), "maintenance rewrote earlier entries");
94
96
  })),
97
+ check("a message's images survive a reopen; the current turn's are built as image parts, an earlier turn's as image parts or label lines", "attachments", (open, log) => Effect.gen(function* () {
98
+ const image = yield* mediaFromBase64(pixelPng, { name: "pixel.png" }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "attachments", message: error.message }))), session = yield* open(log, "run-1");
99
+ yield* session.record([{ _tag: "TurnStarted", userMessage: new UserMessage({ text: "what is this?", attachments: [image] }) }], 0);
100
+ yield* session.record([{ _tag: "TurnEnded", outcome: "completed", reply: Option.some("a pixel") }], 0);
101
+ const other = yield* open(log, "run-2");
102
+ yield* other.record([{ _tag: "TurnStarted", userMessage: new UserMessage({ text: "", attachments: [image] }) }], 0);
103
+ const reopened = yield* open(log, "run-2");
104
+ yield* expect("attachments", encodeEntries(yield* reopened.entries) === encodeEntries(yield* other.entries), "the reopened log lost or changed an image");
105
+ const users = (yield* reopened.build({ stepContext: "tail" })).messages.filter((message) => message.role === "user"), shown = users.map((message) => typeof message.content === "string" ? message.content.includes(mediaLabel(image)) ? "label" : "missing" : message.content.some((part) => part.type === "text" && part.text.length === 0) ? "empty text" : message.content.some((part) => part.type === "media" && part.fingerprint === image.fingerprint && part.data === image.data) ? "image" : "missing"), [earlier, current] = [shown[0] ?? "missing", shown[1] ?? "missing"];
106
+ yield* expect("attachments", shown.length === 2 && (earlier === "image" || earlier === "label") && current === "image", `the earlier message showed its image as ${earlier} (an image or its label line), the current turn's as ${current} (its image, with the same data)`);
107
+ const firstText = Option.match(Option.fromNullishOr(users[0]), {
108
+ onNone: () => "",
109
+ onSome: (message) => typeof message.content === "string" ? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join(`
110
+ `)
111
+ });
112
+ yield* expect("attachments", firstText.includes("what is this?"), "the first message lost its text");
113
+ })),
114
+ check("a tool result's images survive a reopen, stored once, and the current turn's reach the build as the result's images", "tool-media", (open, log) => Effect.gen(function* () {
115
+ const image = yield* mediaFromBase64(pixelPng, { name: "shot.png" }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "tool-media", message: error.message }))), imaging = { ...views, view: (_tool, encoded) => Effect.succeed({ text: "a screenshot", version: "1", subjects: [], artifacts: [], pinned: !1, media: mediaSlotsOf(encoded) }) }, session = yield* open(log, "run-1");
116
+ yield* session.record([said("how does it look?")], 0);
117
+ yield* session.recordTail([
118
+ { role: "assistant", content: [{ type: "tool-call", toolCallId: callId, toolName: "screenshot", input: {} }] },
119
+ { role: "tool", content: [{ type: "tool-result", toolCallId: callId, toolName: "screenshot", output: { shot: image }, isError: !1 }] }
120
+ ], imaging, 0);
121
+ const reopened = yield* open(log, "run-1"), entries = yield* reopened.entries;
122
+ yield* expect("tool-media", encodeEntries(entries) === encodeEntries(yield* session.entries), "the reopened log lost or changed the result's image");
123
+ yield* expect("tool-media", encodeEntries(entries).split(image.data).length - 1 === 1, "the result's image is not stored exactly once");
124
+ const parts = (yield* reopened.build({ stepContext: "tail" })).messages.flatMap((message) => message.role === "tool" ? message.content : []);
125
+ yield* expect("tool-media", parts.some((part) => (part.media ?? []).some((media) => media.fingerprint === image.fingerprint && media.data === image.data)), "the current turn's tool result was built without its image");
126
+ })),
95
127
  check("the reference transcript holds user messages and replies, never tool traffic", "reference", (open, log) => Effect.gen(function* () {
96
128
  const session = yield* open(log, "run-1");
97
129
  yield* recordTurn(session, "find alpha", "found record-alpha");
@@ -0,0 +1,13 @@
1
+ import type { Context } from "effect";
2
+ import type { ConformanceCheck } from "../conformance.entity.js";
3
+ import type { AuthStore } from "../ports/auth-store.port.js";
4
+ type Store = Context.Service.Shape<typeof AuthStore>;
5
+ /**
6
+ * The AuthStore contract, as checks any store must pass: what `get`, `all`
7
+ * and `resolveKey` say about each kind of credential, what `set` and
8
+ * `remove` change, and that providers do not share state. Every check uses
9
+ * fresh provider ids (`conformance-<uuid>`, which no store can refresh) and
10
+ * removes them when it ends, so the kit can run against a shared store.
11
+ */
12
+ export declare const authStoreConformance: (store: Store) => ReadonlyArray<ConformanceCheck>;
13
+ export {};
@@ -0,0 +1,73 @@
1
+ import { Clock, Effect, Option, Redacted } from "effect";
2
+ import { check, expect, fail } from "../conformance.functions.js";
3
+ import { ProviderId } from "../domain/model-selection.entity.js";
4
+ import { canonicalJson } from "../memory/memory-log.entity.functions.js";
5
+ const HOUR = 3600000, freshProvider = Effect.sync(() => ProviderId.make(`conformance-${crypto.randomUUID()}`)), secret = (label) => `conformance-${label}-${crypto.randomUUID()}`, same = (a, b) => Option.isSome(a) && canonicalJson(a.value) === canonicalJson(b), keyOf = (id, resolved) => Option.match(resolved, {
6
+ onNone: () => Effect.succeed(Option.none()),
7
+ onSome: (key) => Redacted.isRedacted(key) ? Effect.succeed(Option.some(Redacted.value(key))) : fail(id, "the resolved key is not Redacted")
8
+ }), withProviders = (store, count, body) => Effect.forEach(Array.from({ length: count }), () => freshProvider).pipe(Effect.flatMap((providers) => body(providers).pipe(Effect.ensuring(Effect.forEach(providers, (provider) => store.remove(provider).pipe(Effect.ignore), { discard: !0 })))));
9
+ export const authStoreConformance = (store) => [
10
+ check("an absent provider has no credential, no key and no entry", "absent", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
11
+ const id = provider;
12
+ yield* expect("absent", Option.isNone(yield* store.get(id)), "get found a credential");
13
+ yield* expect("absent", Option.isNone(yield* store.resolveKey(id)), "resolveKey found a key");
14
+ yield* expect("absent", !(yield* store.all).has(id), "all lists the provider");
15
+ }))),
16
+ check("an api_key credential reads back, is listed, and resolves to its key as Redacted", "api-key", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
17
+ const id = provider, credential = { type: "api_key", key: secret("key") };
18
+ yield* store.set(id, credential);
19
+ yield* expect("api-key", same(yield* store.get(id), credential), "get returned a different credential");
20
+ yield* expect("api-key", same(Option.fromNullishOr((yield* store.all).get(id)), credential), "all lists a different credential, or none");
21
+ const key = yield* keyOf("api-key", yield* store.resolveKey(id));
22
+ yield* expect("api-key", Option.getOrUndefined(key) === credential.key, "resolveKey did not return the stored key");
23
+ }))),
24
+ check("a local credential reads back and resolves to no key", "local", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
25
+ const id = provider, credential = { type: "local", baseUrl: "http://127.0.0.1:1/v1" };
26
+ yield* store.set(id, credential);
27
+ yield* expect("local", same(yield* store.get(id), credential), "get returned a different credential");
28
+ yield* expect("local", Option.isNone(yield* store.resolveKey(id)), "resolveKey returned a key for a local credential");
29
+ }))),
30
+ check("an OAuth token that has not expired resolves to its access token", "oauth-valid", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
31
+ const id = provider, now = yield* Clock.currentTimeMillis, credential = { type: "oauth", access: secret("access"), refresh: secret("refresh"), expires: now + HOUR };
32
+ yield* store.set(id, credential);
33
+ yield* expect("oauth-valid", same(yield* store.get(id), credential), "get returned a different credential");
34
+ const key = yield* keyOf("oauth-valid", yield* store.resolveKey(id));
35
+ yield* expect("oauth-valid", Option.getOrUndefined(key) === credential.access, "resolveKey did not return the access token");
36
+ }))),
37
+ check("an expired OAuth token the store cannot refresh fails with an AuthError naming the provider, never the stale token", "oauth-expired", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
38
+ const id = provider, now = yield* Clock.currentTimeMillis;
39
+ yield* store.set(id, { type: "oauth", access: secret("stale"), refresh: secret("refresh"), expires: now - HOUR });
40
+ const resolved = yield* Effect.result(store.resolveKey(id));
41
+ if (resolved._tag === "Success")
42
+ return yield* fail("oauth-expired", Option.isSome(resolved.success) ? "resolveKey returned the expired token" : "resolveKey found no key instead of failing");
43
+ yield* expect("oauth-expired", resolved.failure._tag === "AuthError" && resolved.failure.provider === id, `failed with ${resolved.failure._tag} for ${resolved.failure.provider}`);
44
+ }))),
45
+ check("a second set replaces the first", "overwrite", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
46
+ const id = provider, second = { type: "api_key", key: secret("second") };
47
+ yield* store.set(id, { type: "api_key", key: secret("first") });
48
+ yield* store.set(id, second);
49
+ yield* expect("overwrite", same(yield* store.get(id), second), "get still returns the first credential");
50
+ const key = yield* keyOf("overwrite", yield* store.resolveKey(id));
51
+ yield* expect("overwrite", Option.getOrUndefined(key) === second.key, "resolveKey still returns the first key");
52
+ }))),
53
+ check("remove deletes a credential wherever it is read, and removing an absent provider succeeds", "remove", () => withProviders(store, 2, ([provider, unknown]) => Effect.gen(function* () {
54
+ const id = provider;
55
+ yield* store.set(id, { type: "api_key", key: secret("removed") });
56
+ yield* store.remove(id);
57
+ yield* expect("remove", Option.isNone(yield* store.get(id)), "get still finds the credential");
58
+ yield* expect("remove", Option.isNone(yield* store.resolveKey(id)), "resolveKey still finds a key");
59
+ yield* expect("remove", !(yield* store.all).has(id), "all still lists the provider");
60
+ yield* store.remove(id);
61
+ yield* store.remove(unknown);
62
+ }))),
63
+ check("providers are isolated: writing or removing one leaves another as it was", "isolation", () => withProviders(store, 2, ([first, second]) => Effect.gen(function* () {
64
+ const a = first, b = second, kept = { type: "api_key", key: secret("kept") };
65
+ yield* store.set(b, kept);
66
+ yield* store.set(a, { type: "api_key", key: secret("other") });
67
+ yield* store.remove(a);
68
+ yield* store.set(a, { type: "local" });
69
+ yield* expect("isolation", same(yield* store.get(b), kept), "another provider's credential changed");
70
+ const key = yield* keyOf("isolation", yield* store.resolveKey(b));
71
+ yield* expect("isolation", Option.getOrUndefined(key) === kept.key, "another provider's key changed");
72
+ })))
73
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { Context } from "effect";
2
+ import type { ConformanceCheck } from "../conformance.entity.js";
3
+ import type { ModelCatalog } from "../ports/model-catalog.port.js";
4
+ type Catalog = Context.Service.Shape<typeof ModelCatalog>;
5
+ /**
6
+ * The ModelCatalog contract: every entry is a `ModelCatalogEntry`, its
7
+ * `selection` is `"<provider>:<modelId>"` of the entry's own `provider`, no
8
+ * selection is listed twice, two lists with nothing changed between them
9
+ * agree, and the selections in `includes` (the models a host expects this
10
+ * catalog to offer) are listed. The kit only reads.
11
+ */
12
+ export declare const modelCatalogConformance: (catalog: Catalog, options?: {
13
+ readonly includes?: ReadonlyArray<string>;
14
+ }) => ReadonlyArray<ConformanceCheck>;
15
+ export {};
@@ -0,0 +1,27 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { check, expect } from "../conformance.functions.js";
3
+ import { ModelCatalogEntry } from "../domain/model-catalog.entity.js";
4
+ import { parseModelSelection } from "../domain/model-selection.entity.functions.js";
5
+ const isEntry = Schema.is(ModelCatalogEntry);
6
+ export const modelCatalogConformance = (catalog, options = {}) => [
7
+ check("every entry is a ModelCatalogEntry", "decode", () => Effect.gen(function* () {
8
+ const invalid = (yield* catalog.list).filter((entry) => !isEntry(entry));
9
+ yield* expect("decode", invalid.length === 0, `entries that do not decode: ${JSON.stringify(invalid.slice(0, 3))}`);
10
+ })),
11
+ check(`every selection is "<provider>:<modelId>" of the entry's provider`, "selection", () => Effect.gen(function* () {
12
+ const wrong = (yield* catalog.list).filter((entry) => !Option.exists(parseModelSelection(entry.selection), (parsed) => parsed.provider === entry.provider));
13
+ yield* expect("selection", wrong.length === 0, `selections that do not name their provider: ${wrong.slice(0, 3).map((entry) => `${entry.selection} (${entry.provider})`).join(", ")}`);
14
+ })),
15
+ check("no selection is listed twice", "unique", () => Effect.gen(function* () {
16
+ const selections = (yield* catalog.list).map((entry) => entry.selection), repeated = selections.filter((selection, index) => selections.indexOf(selection) !== index);
17
+ yield* expect("unique", repeated.length === 0, `listed more than once: ${[...new Set(repeated)].slice(0, 3).join(", ")}`);
18
+ })),
19
+ check("two lists with nothing changed between them agree", "stable", () => Effect.gen(function* () {
20
+ const first = JSON.stringify(yield* catalog.list);
21
+ yield* expect("stable", JSON.stringify(yield* catalog.list) === first, "two lists differ");
22
+ })),
23
+ check("the selections the caller expects are listed", "includes", () => Effect.gen(function* () {
24
+ const listed = new Set((yield* catalog.list).map((entry) => entry.selection)), missing = (options.includes ?? []).filter((selection) => !listed.has(selection));
25
+ yield* expect("includes", missing.length === 0, `not listed: ${missing.join(", ")}`);
26
+ }))
27
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import type { Context } from "effect";
2
+ import type { ConformanceCheck } from "../conformance.entity.js";
3
+ import type { SettingsStore } from "../ports/settings-store.port.js";
4
+ type Store = Context.Service.Shape<typeof SettingsStore>;
5
+ /**
6
+ * The SettingsStore contract. Every store: `load` is stable. A writable
7
+ * store: each role reads back what `setRole` wrote and clearing it returns
8
+ * the value before the set; `set` accepts each key's canonical string form
9
+ * and `load` gives it typed; `None` clears a key back to the value before the
10
+ * set; a value that does not parse (a selection that is not
11
+ * `"<provider>:<modelId>"`, a count below 1, a switch that is not
12
+ * true/false, an unknown effort) fails with a `SettingsError` and changes
13
+ * nothing, through `set` and `setRole` alike. A read-only store refuses
14
+ * every write with a `SettingsError` and changes nothing.
15
+ *
16
+ * The kit writes the store's shared keys (settings have no per-check ids),
17
+ * and restores each key it touches; do not run it against settings that are
18
+ * in use. A key it would touch that holds a value the store must refuse (a
19
+ * selection that is not `"<provider>:<modelId>"`, a count below 1, left by
20
+ * an older, unchecked write) could not be put back, so the check that would
21
+ * touch it fails before writing anything.
22
+ */
23
+ export declare const settingsStoreConformance: (store: Store, options: {
24
+ readonly writable: boolean;
25
+ }) => ReadonlyArray<ConformanceCheck>;
26
+ export {};
@@ -0,0 +1,80 @@
1
+ import { Effect, Option } from "effect";
2
+ import { check, expect, fail } from "../conformance.functions.js";
3
+ import { parseModelSelection } from "../domain/model-selection.entity.functions.js";
4
+ import { canonicalJson } from "../memory/memory-log.entity.functions.js";
5
+ import { SETTINGS_KEYS } from "../ports/settings-store.port.js";
6
+ const ROLE_FIELDS = { general: "model", code: "codeModel", fast: "fastModel" }, ROLES = ["general", "code", "fast"], FIELDS = ["model", "codeModel", "fastModel", ...SETTINGS_KEYS], fieldOf = (settings, field) => settings[field], valueOf = (settings, field) => Option.map(fieldOf(settings, field), String), snapshot = (settings) => canonicalJson(Object.fromEntries(FIELDS.map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), others = (settings, except) => canonicalJson(Object.fromEntries(FIELDS.filter((field) => field !== except).map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), selection = (label) => `conformance:${label}-${crypto.randomUUID()}`, SELECTIONS = new Set(["model", "codeModel", "fastModel", "fallbackModel"]), COUNTS = new Set(["maxAttempts"]), restorable = (field, value) => SELECTIONS.has(field) ? Option.isSome(parseModelSelection(value)) : COUNTS.has(field) ? Number.isInteger(Number(value)) && Number(value) >= 1 : !0, restoring = (store, id, field, write) => (body) => store.load.pipe(Effect.flatMap((before) => Option.match(Option.filter(valueOf(before, field), (value) => !restorable(field, value)), {
7
+ onSome: (value) => fail(id, `the store holds ${field} "${value}", which a conforming store refuses to write, so the kit could not put it back; it changed nothing. Correct the value, then run the kit`),
8
+ onNone: () => body.pipe(Effect.ensuring(store.load.pipe(Effect.flatMap((after) => Option.getOrNull(valueOf(after, field)) === Option.getOrNull(valueOf(before, field)) ? Effect.void : write(valueOf(before, field))), Effect.ignore)))
9
+ }))), restoringRole = (store, id, role) => restoring(store, id, ROLE_FIELDS[role], (value) => store.setRole(role, value)), restoringKey = (store, id, key) => restoring(store, id, key, (value) => store.set(key, value)), refused = (store, id, what, write) => Effect.gen(function* () {
10
+ const before = snapshot(yield* store.load), result = yield* Effect.result(write);
11
+ if (result._tag === "Success")
12
+ return yield* fail(id, `${what} was accepted`);
13
+ const tag = typeof result.failure === "object" && result.failure !== null && "_tag" in result.failure ? String(result.failure._tag) : "an error";
14
+ yield* expect(id, tag === "SettingsError", `${what} failed with ${tag}, not a SettingsError`);
15
+ yield* expect(id, snapshot(yield* store.load) === before, `${what} changed the settings`);
16
+ }), CANONICAL = [
17
+ { key: "maxAttempts", text: "3", reads: 3 },
18
+ { key: "sandbox", text: "true", reads: !0 },
19
+ { key: "reasoningEffort", text: "high", reads: "high" }
20
+ ], CLEARED = [
21
+ { key: "maxAttempts", texts: ["4", "5"] },
22
+ { key: "sandbox", texts: ["false", "true"] },
23
+ { key: "reasoningEffort", texts: ["low", "medium"] }
24
+ ], BAD = [
25
+ { key: "fallbackModel", text: "not-a-selection" },
26
+ { key: "maxAttempts", text: "0" },
27
+ { key: "maxAttempts", text: "two" },
28
+ { key: "sandbox", text: "maybe" },
29
+ { key: "reasoningEffort", text: "extreme" }
30
+ ];
31
+ export const settingsStoreConformance = (store, options) => [
32
+ check("load is stable: two loads with no write between them agree", "load", () => Effect.gen(function* () {
33
+ const first = snapshot(yield* store.load);
34
+ yield* expect("load", snapshot(yield* store.load) === first, "two loads disagree");
35
+ })),
36
+ ...options.writable ? writableChecks(store) : readOnlyChecks(store)
37
+ ];
38
+ const writableChecks = (store) => [
39
+ check("each role reads back what setRole wrote, and clearing it returns the value before the set", "role", () => Effect.forEach(ROLES, (role) => Effect.gen(function* () {
40
+ const field = ROLE_FIELDS[role];
41
+ yield* store.setRole(role, Option.none());
42
+ const base = yield* store.load, value = selection(role);
43
+ yield* store.setRole(role, Option.some(value));
44
+ const set = yield* store.load;
45
+ yield* expect("role", Option.getOrUndefined(valueOf(set, field)) === value, `the ${role} role reads ${Option.getOrElse(valueOf(set, field), () => "nothing")}, not what was set`);
46
+ yield* expect("role", others(set, field) === others(base, field), `setting the ${role} role changed another value`);
47
+ yield* store.setRole(role, Option.none());
48
+ yield* expect("role", snapshot(yield* store.load) === snapshot(base), `clearing the ${role} role did not return the value before the set`);
49
+ }).pipe(restoringRole(store, "role", role)), { discard: !0 })),
50
+ check("set accepts each key's canonical string form and load reads it back typed", "values", () => Effect.forEach([
51
+ ...CANONICAL,
52
+ { key: "fallbackModel", text: selection("fallback"), reads: void 0 }
53
+ ], ({ key, text, reads }) => Effect.gen(function* () {
54
+ const before = yield* store.load;
55
+ yield* store.set(key, Option.some(text));
56
+ const after = yield* store.load, expected = reads ?? text, read = Option.getOrNull(fieldOf(after, key));
57
+ yield* expect("values", read === expected, `${key} "${text}" reads back as ${JSON.stringify(read)}`);
58
+ yield* expect("values", others(after, key) === others(before, key), `setting ${key} changed another value`);
59
+ }).pipe(restoringKey(store, "values", key)), { discard: !0 })),
60
+ check("None clears a key back to the value before the set", "clear", () => Effect.forEach(CLEARED, ({ key, texts }) => Effect.gen(function* () {
61
+ yield* store.set(key, Option.none());
62
+ const base = yield* store.load, text = texts.find((candidate) => Option.getOrNull(valueOf(base, key)) !== candidate) ?? texts[0];
63
+ yield* store.set(key, Option.some(text));
64
+ yield* store.set(key, Option.none());
65
+ yield* expect("clear", snapshot(yield* store.load) === snapshot(base), `clearing ${key} did not return the value before the set`);
66
+ }).pipe(restoringKey(store, "clear", key)), { discard: !0 })),
67
+ check("set refuses a value that does not parse with a SettingsError and changes nothing", "bad-values", () => Effect.forEach(BAD, ({ key, text }) => refused(store, "bad-values", `${key} "${text}"`, store.set(key, Option.some(text))).pipe(restoringKey(store, "bad-values", key)), { discard: !0 })),
68
+ check('setRole refuses a selection that is not "<provider>:<modelId>" with a SettingsError and changes nothing', "bad-role", () => Effect.forEach(ROLES, (role) => Effect.forEach(["not-a-selection", ":model", "provider:"], (text) => refused(store, "bad-role", `the ${role} role "${text}"`, store.setRole(role, Option.some(text))), { discard: !0 }).pipe(restoringRole(store, "bad-role", role)), { discard: !0 }))
69
+ ], readOnlyChecks = (store) => [
70
+ check("a read-only store refuses every write with a SettingsError and changes nothing", "read-only", () => Effect.gen(function* () {
71
+ yield* Effect.forEach(ROLES, (role) => Effect.all([
72
+ refused(store, "read-only", `setRole ${role}`, store.setRole(role, Option.some(selection(role)))),
73
+ refused(store, "read-only", `clearing the ${role} role`, store.setRole(role, Option.none()))
74
+ ]), { discard: !0 });
75
+ yield* Effect.forEach(CANONICAL, ({ key, text }) => Effect.all([
76
+ refused(store, "read-only", `set ${key}`, store.set(key, Option.some(text))),
77
+ refused(store, "read-only", `clearing ${key}`, store.set(key, Option.none()))
78
+ ]), { discard: !0 });
79
+ }))
80
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { Context } from "effect";
2
+ import type { Effect } from "effect";
3
+ import type { TurnsInFlight } from "../session/activity.entity.js";
4
+ declare const TurnActivity_base: Context.ServiceClass<TurnActivity, "efferent/TurnActivity", {
5
+ /** The turns held open now (oldest first) and the begins in progress, read at one moment. */
6
+ readonly current: Effect.Effect<TurnsInFlight>;
7
+ /** Completes once no turn is held open and none is beginning (at once when so). */
8
+ readonly idle: Effect.Effect<void>;
9
+ }>;
10
+ /**
11
+ * The turns this process has in flight, from the Sessions service that opens
12
+ * them (`SessionsLive` provides it). A begin counts from the moment it is
13
+ * asked for, through the host's admission and the opening commit, until its
14
+ * writer is handed over (or it fails); the turn then counts as open until
15
+ * its writer's scope closes. Only this process's turns count: one held by
16
+ * another process over the same log does not. `processActivity` and
17
+ * `awaitIdle` read it, when it is there.
18
+ */
19
+ export declare class TurnActivity extends TurnActivity_base {
20
+ }
21
+ declare const TaskActivity_base: Context.ServiceClass<TaskActivity, "efferent/TaskActivity", {
22
+ /** The pieces of work running now. */
23
+ readonly running: Effect.Effect<number>;
24
+ /** Completes once none runs (at once when none does). */
25
+ readonly idle: Effect.Effect<void>;
26
+ }>;
27
+ /**
28
+ * The background work this process runs, from the TaskRunner that runs it
29
+ * (`InProcessTaskRunnerLive` provides it). A piece of work counts from the
30
+ * moment `run` hands it over until it ends. `processActivity` and
31
+ * `awaitIdle` read it, when it is there.
32
+ */
33
+ export declare class TaskActivity extends TaskActivity_base {
34
+ }
35
+ export {};