@pikku/core 0.12.20 → 0.12.22

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 (141) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/handle-error.js +3 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/middleware/index.d.ts +2 -2
  10. package/dist/middleware/index.js +2 -2
  11. package/dist/middleware/telemetry.d.ts +2 -2
  12. package/dist/middleware/telemetry.js +2 -2
  13. package/dist/middleware-runner.d.ts +15 -27
  14. package/dist/middleware-runner.js +25 -30
  15. package/dist/permissions.d.ts +15 -22
  16. package/dist/permissions.js +30 -25
  17. package/dist/pikku-request.js +1 -1
  18. package/dist/pikku-state.js +2 -3
  19. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  20. package/dist/services/in-memory-queue-service.js +1 -1
  21. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  22. package/dist/services/in-memory-workflow-service.js +31 -13
  23. package/dist/services/local-content.js +8 -1
  24. package/dist/services/user-session-service.d.ts +1 -1
  25. package/dist/testing/service-tests.js +24 -0
  26. package/dist/types/core.types.d.ts +4 -0
  27. package/dist/types/state.types.d.ts +2 -18
  28. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  29. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  30. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  31. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  32. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  33. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  34. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  35. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  36. package/dist/wirings/channel/channel-handler.js +1 -1
  37. package/dist/wirings/channel/channel-store.d.ts +13 -0
  38. package/dist/wirings/channel/channel.types.d.ts +3 -0
  39. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  40. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  41. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  42. package/dist/wirings/cli/cli-runner.js +24 -5
  43. package/dist/wirings/cli/command-parser.js +19 -4
  44. package/dist/wirings/http/http-runner.js +72 -36
  45. package/dist/wirings/http/http.types.d.ts +1 -0
  46. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  47. package/dist/wirings/http/web-request.js +32 -0
  48. package/dist/wirings/rpc/rpc-runner.js +13 -3
  49. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  50. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  51. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  52. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  53. package/dist/wirings/workflow/index.d.ts +1 -1
  54. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  55. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  56. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  57. package/package.json +1 -1
  58. package/run-tests.sh +4 -1
  59. package/src/dev/hot-reload.test.ts +62 -11
  60. package/src/dev/hot-reload.ts +28 -7
  61. package/src/errors/error-handler.ts +8 -2
  62. package/src/errors/error.test.ts +2 -0
  63. package/src/function/function-runner.test.ts +500 -10
  64. package/src/function/functions.types.ts +3 -2
  65. package/src/handle-error.test.ts +1 -1
  66. package/src/handle-error.ts +4 -1
  67. package/src/index.ts +12 -2
  68. package/src/middleware/index.ts +11 -2
  69. package/src/middleware/telemetry.ts +2 -2
  70. package/src/middleware-runner.test.ts +16 -16
  71. package/src/middleware-runner.ts +42 -30
  72. package/src/permissions.test.ts +27 -24
  73. package/src/permissions.ts +41 -25
  74. package/src/pikku-request.test.ts +35 -0
  75. package/src/pikku-request.ts +1 -1
  76. package/src/pikku-state.ts +2 -3
  77. package/src/run-tests-script.test.ts +18 -0
  78. package/src/services/ai-agent-runner-service.ts +1 -0
  79. package/src/services/in-memory-queue-service.ts +1 -1
  80. package/src/services/in-memory-session-store.ts +1 -2
  81. package/src/services/in-memory-workflow-service.test.ts +33 -11
  82. package/src/services/in-memory-workflow-service.ts +49 -13
  83. package/src/services/local-content.test.ts +54 -0
  84. package/src/services/local-content.ts +12 -1
  85. package/src/services/typed-credential-service.ts +3 -3
  86. package/src/services/typed-secret-service.ts +3 -3
  87. package/src/services/typed-variables-service.ts +3 -3
  88. package/src/services/user-session-service.ts +4 -4
  89. package/src/testing/service-tests.ts +30 -0
  90. package/src/types/core.types.ts +6 -2
  91. package/src/types/state.types.ts +2 -13
  92. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  93. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  94. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  95. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  96. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  97. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  98. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  99. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  100. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  101. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  102. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  103. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  104. package/src/wirings/channel/channel-handler.test.ts +272 -0
  105. package/src/wirings/channel/channel-handler.ts +1 -1
  106. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  107. package/src/wirings/channel/channel-store.ts +19 -0
  108. package/src/wirings/channel/channel.types.ts +4 -0
  109. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  110. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  111. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  112. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  113. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  114. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  115. package/src/wirings/cli/cli-runner.test.ts +255 -2
  116. package/src/wirings/cli/cli-runner.ts +31 -10
  117. package/src/wirings/cli/cli.types.ts +4 -8
  118. package/src/wirings/cli/command-parser.test.ts +83 -0
  119. package/src/wirings/cli/command-parser.ts +23 -4
  120. package/src/wirings/http/http-runner.test.ts +296 -1
  121. package/src/wirings/http/http-runner.ts +87 -57
  122. package/src/wirings/http/http.types.ts +11 -26
  123. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  124. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  125. package/src/wirings/http/web-request.test.ts +115 -0
  126. package/src/wirings/http/web-request.ts +43 -5
  127. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  128. package/src/wirings/queue/queue.types.ts +2 -5
  129. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  130. package/src/wirings/rpc/rpc-runner.ts +15 -3
  131. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  132. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  133. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  134. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  135. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  136. package/src/wirings/workflow/index.ts +1 -0
  137. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  138. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  139. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  140. package/src/wirings/workflow/workflow.types.ts +68 -5
  141. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,324 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resetPikkuState } from '../../pikku-state.js'
5
+ import {
6
+ buildWorkingMemoryPrompt,
7
+ createWorkingMemoryMiddleware,
8
+ deepMergeWorkingMemory,
9
+ loadContextMessages,
10
+ parseWorkingMemory,
11
+ resolveMemoryServices,
12
+ saveMessages,
13
+ stripWorkingMemoryTags,
14
+ trimMessages,
15
+ } from './ai-agent-memory.js'
16
+ import type { AIMessage, CoreAIAgent } from './ai-agent.types.js'
17
+
18
+ beforeEach(() => {
19
+ resetPikkuState()
20
+ })
21
+
22
+ const makeMessage = (overrides: Partial<AIMessage>): AIMessage => ({
23
+ id: 'msg',
24
+ role: 'user',
25
+ content: 'hello',
26
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
27
+ ...overrides,
28
+ })
29
+
30
+ describe('ai-agent-memory', () => {
31
+ test('resolveMemoryServices prefers named storage and falls back to aiStorage', () => {
32
+ const singletonServices = {
33
+ aiStorage: { kind: 'default' },
34
+ customStorage: { kind: 'custom' },
35
+ } as any
36
+
37
+ const defaultAgent = {} as CoreAIAgent
38
+ const namedAgent = { memory: { storage: 'customStorage' } } as CoreAIAgent
39
+
40
+ assert.equal(
41
+ resolveMemoryServices(defaultAgent, singletonServices).storage,
42
+ singletonServices.aiStorage
43
+ )
44
+ assert.equal(
45
+ resolveMemoryServices(namedAgent, singletonServices).storage,
46
+ singletonServices.customStorage
47
+ )
48
+ })
49
+
50
+ test('deepMergeWorkingMemory merges nested objects and deletes null fields', () => {
51
+ const result = deepMergeWorkingMemory(
52
+ {
53
+ profile: {
54
+ name: 'Yasser',
55
+ stats: { commits: 1, reviews: 2 },
56
+ },
57
+ stale: true,
58
+ },
59
+ {
60
+ profile: {
61
+ stats: { reviews: 3 },
62
+ },
63
+ stale: null,
64
+ active: false,
65
+ }
66
+ )
67
+
68
+ assert.deepEqual(result, {
69
+ profile: {
70
+ name: 'Yasser',
71
+ stats: { commits: 1, reviews: 3 },
72
+ },
73
+ active: false,
74
+ })
75
+ })
76
+
77
+ test('deepMergeWorkingMemory ignores prototype-polluting keys', () => {
78
+ const before = ({} as any).isAdmin
79
+ deepMergeWorkingMemory(
80
+ {},
81
+ JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}')
82
+ )
83
+ assert.equal(({} as any).isAdmin, before)
84
+
85
+ deepMergeWorkingMemory({}, JSON.parse('{"__proto__":{"isAdmin":true}}'))
86
+ assert.equal(({} as any).isAdmin, before)
87
+
88
+ deepMergeWorkingMemory({}, { prototype: { isAdmin: true } })
89
+ assert.equal(({} as any).isAdmin, before)
90
+ })
91
+
92
+ test('buildWorkingMemoryPrompt includes schema fields and current state', () => {
93
+ const prompt = buildWorkingMemoryPrompt(
94
+ { city: 'Berlin' },
95
+ {
96
+ properties: {
97
+ city: { type: 'string', description: 'Current city' },
98
+ age: { type: 'number' },
99
+ },
100
+ }
101
+ )
102
+
103
+ assert.match(prompt, /Working memory fields:/)
104
+ assert.match(prompt, /city \(string\) - Current city/)
105
+ assert.match(prompt, /age \(number\)/)
106
+ assert.match(prompt, /Current working memory:\n\{"city":"Berlin"\}/)
107
+ assert.match(prompt, /<working_memory>/)
108
+ })
109
+
110
+ test('buildWorkingMemoryPrompt marks empty memory explicitly', () => {
111
+ const prompt = buildWorkingMemoryPrompt(null)
112
+ assert.match(prompt, /Current working memory: \(empty\)/)
113
+ })
114
+
115
+ test('loadContextMessages injects working memory system prompt when enabled', async () => {
116
+ const storage = {
117
+ getWorkingMemory: async () => ({ city: 'Berlin' }),
118
+ } as any
119
+
120
+ const messages = await loadContextMessages(
121
+ { workingMemory: true },
122
+ storage,
123
+ { message: 'hello', threadId: 'thread-1', resourceId: 'resource-1' },
124
+ { properties: { city: { type: 'string' } } }
125
+ )
126
+
127
+ assert.equal(messages.length, 1)
128
+ assert.equal(messages[0].role, 'system')
129
+ assert.match(String(messages[0].content), /Current working memory:/)
130
+ })
131
+
132
+ test('loadContextMessages returns no messages without storage', async () => {
133
+ const messages = await loadContextMessages(
134
+ { workingMemory: true },
135
+ undefined,
136
+ { message: 'hello', threadId: 'thread-1', resourceId: 'resource-1' }
137
+ )
138
+ assert.deepEqual(messages, [])
139
+ })
140
+
141
+ test('saveMessages persists tool calls and strips working memory tags from returned text', async () => {
142
+ const savedMessages: AIMessage[][] = []
143
+
144
+ const storage = {
145
+ saveMessages: async (_threadId: string, messages: AIMessage[]) => {
146
+ savedMessages.push(messages)
147
+ },
148
+ } as any
149
+
150
+ const resultText = await saveMessages(
151
+ storage,
152
+ 'thread-1',
153
+ 'resource-1',
154
+ { workingMemory: true },
155
+ makeMessage({ id: 'user-1' }),
156
+ {
157
+ text: 'Done <working_memory>{"city":"Berlin"}</working_memory>',
158
+ steps: [
159
+ {
160
+ toolCalls: [
161
+ {
162
+ name: 'tool-1',
163
+ args: { q: 'hello' },
164
+ result: 'world',
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ }
170
+ )
171
+
172
+ assert.equal(resultText, 'Done')
173
+ assert.equal(savedMessages.length, 1)
174
+ assert.equal(savedMessages[0].length, 4)
175
+ assert.equal(savedMessages[0][0].role, 'user')
176
+ assert.equal(savedMessages[0][1].role, 'assistant')
177
+ assert.equal(savedMessages[0][2].role, 'tool')
178
+ assert.equal(savedMessages[0][3].role, 'assistant')
179
+ })
180
+
181
+ test('saveMessages returns text unchanged when no storage is configured', async () => {
182
+ const passthrough = await saveMessages(
183
+ undefined,
184
+ 'thread-1',
185
+ 'resource-1',
186
+ { workingMemory: true },
187
+ null,
188
+ { text: 'plain text', steps: [] }
189
+ )
190
+ assert.equal(passthrough, 'plain text')
191
+ })
192
+
193
+ test('createWorkingMemoryMiddleware merges, validates, and persists working memory', async () => {
194
+ const savedWorkingMemory: unknown[] = []
195
+ const warnings: string[] = []
196
+
197
+ const storage = {
198
+ getWorkingMemory: async () => ({ city: 'Paris', removeMe: 'x' }),
199
+ saveWorkingMemory: async (
200
+ threadId: string,
201
+ scope: string,
202
+ value: unknown
203
+ ) => {
204
+ savedWorkingMemory.push({ threadId, scope, value })
205
+ },
206
+ } as any
207
+
208
+ const mw = createWorkingMemoryMiddleware({
209
+ storage,
210
+ threadId: 'thread-1',
211
+ workingMemorySchemaName: 'WorkingMemory',
212
+ schemaService: { validateSchema: async () => {} } as any,
213
+ logger: { warn: (message: string) => warnings.push(message) } as any,
214
+ })
215
+
216
+ const result = (await mw.modifyOutput!({} as any, {
217
+ text: 'Done <working_memory>{"city":"Berlin","removeMe":null}</working_memory>',
218
+ messages: [],
219
+ usage: {} as any,
220
+ })) as { text: string }
221
+
222
+ assert.equal(result.text, 'Done')
223
+ assert.deepEqual(savedWorkingMemory, [
224
+ {
225
+ threadId: 'thread-1',
226
+ scope: 'thread',
227
+ value: { city: 'Berlin' },
228
+ },
229
+ ])
230
+ assert.deepEqual(warnings, [])
231
+ })
232
+
233
+ test('createWorkingMemoryMiddleware warns and skips persistence on schema failure', async () => {
234
+ const savedWorkingMemory: unknown[] = []
235
+ const warnings: string[] = []
236
+
237
+ const storage = {
238
+ getWorkingMemory: async () => ({}),
239
+ saveWorkingMemory: async (...args: unknown[]) => {
240
+ savedWorkingMemory.push(args)
241
+ },
242
+ } as any
243
+
244
+ const mw = createWorkingMemoryMiddleware({
245
+ storage,
246
+ threadId: 'thread-1',
247
+ workingMemorySchemaName: 'WorkingMemory',
248
+ schemaService: {
249
+ validateSchema: async () => {
250
+ throw new Error('bad schema')
251
+ },
252
+ } as any,
253
+ logger: { warn: (message: string) => warnings.push(message) } as any,
254
+ })
255
+
256
+ const result = (await mw.modifyOutput!({} as any, {
257
+ text: '<working_memory>{"city":"Berlin"}</working_memory>',
258
+ messages: [],
259
+ usage: {} as any,
260
+ })) as { text: string }
261
+
262
+ assert.equal(result.text, '')
263
+ assert.deepEqual(warnings, ['Working memory validation failed: bad schema'])
264
+ assert.deepEqual(savedWorkingMemory, [])
265
+ })
266
+
267
+ test('trimMessages sanitizes orphaned tool messages and keeps first user/system boundary', () => {
268
+ const messages: AIMessage[] = [
269
+ makeMessage({
270
+ id: 'assistant-1',
271
+ role: 'assistant',
272
+ content: 'tool call text',
273
+ toolCalls: [{ id: 'tc-1', name: 'tool', args: { x: 1 } }],
274
+ }),
275
+ makeMessage({
276
+ id: 'tool-1',
277
+ role: 'tool',
278
+ toolResults: [{ id: 'other', name: 'tool', result: 'wrong' }],
279
+ }),
280
+ makeMessage({
281
+ id: 'user-1',
282
+ role: 'user',
283
+ content: 'real user message',
284
+ }),
285
+ makeMessage({
286
+ id: 'assistant-2',
287
+ role: 'assistant',
288
+ content: 'final answer',
289
+ }),
290
+ ]
291
+
292
+ const trimmed = trimMessages(messages, 1000)
293
+ assert.equal(trimmed[0].role, 'user')
294
+ assert.equal(trimmed[1].role, 'assistant')
295
+ assert.equal(trimmed[1].content, 'final answer')
296
+ })
297
+
298
+ test('trimMessages keeps latest message even when over token budget', () => {
299
+ const trimmed = trimMessages(
300
+ [makeMessage({ content: 'a'.repeat(1000) })],
301
+ 1
302
+ )
303
+ assert.equal(trimmed.length, 1)
304
+ })
305
+
306
+ test('parseWorkingMemory and stripWorkingMemoryTags handle valid and invalid payloads', () => {
307
+ assert.deepEqual(
308
+ parseWorkingMemory(
309
+ 'a <working_memory>{"city":"Berlin"}</working_memory> b'
310
+ ),
311
+ { city: 'Berlin' }
312
+ )
313
+ assert.equal(
314
+ parseWorkingMemory('<working_memory>{oops}</working_memory>'),
315
+ null
316
+ )
317
+ assert.equal(
318
+ stripWorkingMemoryTags(
319
+ 'before <working_memory>{"city":"Berlin"}</working_memory> after'
320
+ ),
321
+ 'before after'.trim()
322
+ )
323
+ })
324
+ })
@@ -9,6 +9,7 @@ import type {
9
9
  import type { AIStorageService } from '../../services/ai-storage-service.js'
10
10
  import type { Logger } from '../../services/logger.js'
11
11
  import type { SchemaService } from '../../services/schema-service.js'
12
+ import type { PikkuAIMiddlewareHooks } from './ai-agent.types.js'
12
13
 
13
14
  export function resolveMemoryServices(
14
15
  agent: CoreAIAgent,
@@ -23,12 +24,22 @@ export function resolveMemoryServices(
23
24
  return { storage }
24
25
  }
25
26
 
27
+ export function isWorkingMemoryEnabled(
28
+ memoryConfig: AIAgentMemoryConfig | undefined,
29
+ storage: AIStorageService | undefined
30
+ ): boolean {
31
+ return !!memoryConfig?.workingMemory && !!storage
32
+ }
33
+
26
34
  export function deepMergeWorkingMemory(
27
35
  existing: Record<string, unknown>,
28
36
  updates: Record<string, unknown>
29
37
  ): Record<string, unknown> {
30
38
  const result = { ...existing }
31
39
  for (const key of Object.keys(updates)) {
40
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
41
+ continue
42
+ }
32
43
  const value = updates[key]
33
44
  if (value === null) {
34
45
  delete result[key]
@@ -79,7 +90,9 @@ export function buildWorkingMemoryPrompt(
79
90
 
80
91
  parts.push(
81
92
  'When you learn new information, output a partial JSON update in <working_memory> tags. ' +
82
- 'Only include changed fields. Set a field to null to delete it.'
93
+ 'Only include durable facts you have actually derived or the user has confirmed. ' +
94
+ 'Do not output templates, placeholders, or narration. ' +
95
+ 'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it.'
83
96
  )
84
97
 
85
98
  return parts.join('\n\n')
@@ -93,8 +106,15 @@ export async function loadContextMessages(
93
106
  ): Promise<AIMessage[]> {
94
107
  const contextMessages: AIMessage[] = []
95
108
 
96
- if (memoryConfig?.workingMemory && storage) {
97
- const workingMem = await storage.getWorkingMemory(input.threadId, 'thread')
109
+ const workingMemoryStorage = isWorkingMemoryEnabled(memoryConfig, storage)
110
+ ? storage
111
+ : undefined
112
+
113
+ if (workingMemoryStorage) {
114
+ const workingMem = await workingMemoryStorage.getWorkingMemory(
115
+ input.threadId,
116
+ 'thread'
117
+ )
98
118
  const prompt = buildWorkingMemoryPrompt(workingMem, workingMemoryJsonSchema)
99
119
  contextMessages.push({
100
120
  id: `wm-${randomUUID()}`,
@@ -115,6 +135,7 @@ export async function saveMessages(
115
135
  userMessage: AIMessage | null | undefined,
116
136
  result: {
117
137
  text: string
138
+ uiSpec?: unknown
118
139
  steps: {
119
140
  toolCalls?: {
120
141
  name: string
@@ -122,15 +143,11 @@ export async function saveMessages(
122
143
  result: string
123
144
  }[]
124
145
  }[]
125
- },
126
- options?: {
127
- workingMemoryJsonSchema?: Record<string, unknown>
128
- workingMemorySchemaName?: string | null
129
- logger?: Logger
130
- schemaService?: SchemaService
131
146
  }
132
147
  ): Promise<string> {
133
- let responseText = result.text
148
+ const responseText = memoryConfig?.workingMemory
149
+ ? extractWorkingMemory(result.text).cleanedText
150
+ : result.text
134
151
 
135
152
  if (storage) {
136
153
  const newMessages: AIMessage[] = userMessage ? [userMessage] : []
@@ -161,39 +178,24 @@ export async function saveMessages(
161
178
  }
162
179
  }
163
180
 
181
+ const assistantContent =
182
+ result.uiSpec != null
183
+ ? [
184
+ ...(responseText
185
+ ? [{ type: 'text' as const, text: responseText }]
186
+ : []),
187
+ { type: 'generative-ui' as const, spec: result.uiSpec },
188
+ ]
189
+ : responseText
190
+
164
191
  newMessages.push({
165
192
  id: randomUUID(),
166
193
  role: 'assistant',
167
- content: responseText,
194
+ content: assistantContent || undefined,
168
195
  createdAt: new Date(),
169
196
  })
170
197
 
171
198
  await storage.saveMessages(threadId, newMessages)
172
-
173
- if (memoryConfig?.workingMemory) {
174
- const parsed = parseWorkingMemory(responseText)
175
- if (parsed) {
176
- const existing =
177
- (await storage.getWorkingMemory(threadId, 'thread')) ?? {}
178
- const merged = deepMergeWorkingMemory(existing, parsed)
179
-
180
- if (options?.schemaService && options?.workingMemorySchemaName) {
181
- try {
182
- await options.schemaService.validateSchema(
183
- options.workingMemorySchemaName,
184
- merged
185
- )
186
- } catch (err) {
187
- options.logger?.warn(
188
- `Working memory validation failed: ${err instanceof Error ? err.message : String(err)}`
189
- )
190
- }
191
- }
192
-
193
- await storage.saveWorkingMemory(threadId, 'thread', merged)
194
- responseText = stripWorkingMemoryTags(responseText)
195
- }
196
- }
197
199
  }
198
200
 
199
201
  return responseText
@@ -308,3 +310,152 @@ export function parseWorkingMemory(
308
310
  export function stripWorkingMemoryTags(text: string): string {
309
311
  return text.replace(/<working_memory>[\s\S]*?<\/working_memory>/g, '').trim()
310
312
  }
313
+
314
+ export function extractWorkingMemory(text: string): {
315
+ workingMemory: Record<string, unknown> | null
316
+ cleanedText: string
317
+ } {
318
+ const workingMemory = parseWorkingMemory(text)
319
+ return {
320
+ workingMemory,
321
+ cleanedText: workingMemory ? stripWorkingMemoryTags(text) : text,
322
+ }
323
+ }
324
+
325
+ function getPendingWorkingMemoryPrefixLength(text: string): number {
326
+ const prefixes = ['<working_memory>', '</working_memory>']
327
+ const lastLt = text.lastIndexOf('<')
328
+ if (lastLt === -1) return 0
329
+
330
+ const suffix = text.slice(lastLt)
331
+ for (const prefix of prefixes) {
332
+ if (prefix.startsWith(suffix)) {
333
+ return suffix.length
334
+ }
335
+ }
336
+
337
+ return 0
338
+ }
339
+
340
+ export function stripWorkingMemoryForStreaming(text: string): string {
341
+ let output = ''
342
+ let cursor = 0
343
+
344
+ while (cursor < text.length) {
345
+ const openIndex = text.indexOf('<working_memory>', cursor)
346
+ if (openIndex === -1) {
347
+ const tail = text.slice(cursor)
348
+ const pendingPrefixLength = getPendingWorkingMemoryPrefixLength(tail)
349
+ output +=
350
+ pendingPrefixLength > 0 ? tail.slice(0, -pendingPrefixLength) : tail
351
+ return output
352
+ }
353
+
354
+ output += text.slice(cursor, openIndex)
355
+ const closeIndex = text.indexOf('</working_memory>', openIndex)
356
+ if (closeIndex === -1) {
357
+ return output
358
+ }
359
+
360
+ cursor = closeIndex + '</working_memory>'.length
361
+ }
362
+
363
+ return output
364
+ }
365
+
366
+ export function createWorkingMemoryMiddleware(options: {
367
+ storage?: AIStorageService
368
+ threadId: string
369
+ workingMemorySchemaName?: string | null
370
+ logger?: Logger
371
+ schemaService?: SchemaService
372
+ }): PikkuAIMiddlewareHooks<{
373
+ rawText?: string
374
+ emittedVisibleText?: string
375
+ }> {
376
+ return {
377
+ modifyOutputStream: async (_services, { event, state }) => {
378
+ if (event.type !== 'text-delta') return event
379
+
380
+ const rawText = `${state.rawText ?? ''}${event.text}`
381
+ state.rawText = rawText
382
+
383
+ const visibleText = stripWorkingMemoryForStreaming(rawText)
384
+ const emittedVisibleText = state.emittedVisibleText ?? ''
385
+
386
+ if (!visibleText.startsWith(emittedVisibleText)) {
387
+ state.emittedVisibleText = visibleText
388
+ return { ...event, text: visibleText }
389
+ }
390
+
391
+ const delta = visibleText.slice(emittedVisibleText.length)
392
+ state.emittedVisibleText = visibleText
393
+ if (!delta) return null
394
+ return { ...event, text: delta }
395
+ },
396
+ modifyOutput: async (_services, { text, messages, usage }) => {
397
+ const { workingMemory, cleanedText } = extractWorkingMemory(text)
398
+ if (workingMemory && options.storage) {
399
+ const existing =
400
+ (await options.storage.getWorkingMemory(
401
+ options.threadId,
402
+ 'thread'
403
+ )) ?? {}
404
+ const merged = deepMergeWorkingMemory(existing, workingMemory)
405
+
406
+ let valid = true
407
+ if (options.schemaService && options.workingMemorySchemaName) {
408
+ try {
409
+ await options.schemaService.validateSchema(
410
+ options.workingMemorySchemaName,
411
+ merged
412
+ )
413
+ } catch (err) {
414
+ valid = false
415
+ options.logger?.warn(
416
+ `Working memory validation failed: ${err instanceof Error ? err.message : String(err)}`
417
+ )
418
+ }
419
+ }
420
+
421
+ // Only persist when the merged value passes schema validation —
422
+ // saving invalid data would poison subsequent getWorkingMemory reads.
423
+ if (valid) {
424
+ await options.storage.saveWorkingMemory(
425
+ options.threadId,
426
+ 'thread',
427
+ merged
428
+ )
429
+ }
430
+ }
431
+
432
+ return {
433
+ text: cleanedText,
434
+ messages,
435
+ }
436
+ },
437
+ }
438
+ }
439
+
440
+ export function getWorkingMemoryMiddleware(
441
+ memoryConfig: AIAgentMemoryConfig | undefined,
442
+ storage: AIStorageService | undefined,
443
+ options: {
444
+ threadId: string
445
+ workingMemorySchemaName?: string | null
446
+ logger?: Logger
447
+ schemaService?: SchemaService
448
+ }
449
+ ): PikkuAIMiddlewareHooks[] {
450
+ if (!isWorkingMemoryEnabled(memoryConfig, storage)) return []
451
+
452
+ return [
453
+ createWorkingMemoryMiddleware({
454
+ storage,
455
+ threadId: options.threadId,
456
+ workingMemorySchemaName: options.workingMemorySchemaName,
457
+ logger: options.logger,
458
+ schemaService: options.schemaService,
459
+ }),
460
+ ]
461
+ }