@pikku/core 0.12.19 → 0.12.21

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 (146) hide show
  1. package/CHANGELOG.md +77 -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/function-runner.js +53 -3
  6. package/dist/function/functions.types.d.ts +3 -2
  7. package/dist/index.d.ts +3 -3
  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/content-service.d.ts +66 -37
  21. package/dist/services/in-memory-queue-service.js +1 -1
  22. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  23. package/dist/services/in-memory-workflow-service.js +31 -13
  24. package/dist/services/index.d.ts +1 -1
  25. package/dist/services/local-content.d.ts +10 -12
  26. package/dist/services/local-content.js +54 -38
  27. package/dist/services/user-session-service.d.ts +1 -1
  28. package/dist/testing/service-tests.js +24 -0
  29. package/dist/types/core.types.d.ts +4 -0
  30. package/dist/types/state.types.d.ts +2 -18
  31. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  32. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  33. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  34. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  35. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  36. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  37. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  38. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  39. package/dist/wirings/channel/channel-handler.js +1 -1
  40. package/dist/wirings/channel/channel-store.d.ts +13 -0
  41. package/dist/wirings/channel/channel.types.d.ts +3 -0
  42. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  43. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  44. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  45. package/dist/wirings/cli/cli-runner.js +24 -5
  46. package/dist/wirings/cli/command-parser.js +19 -4
  47. package/dist/wirings/http/http-runner.js +72 -36
  48. package/dist/wirings/http/http.types.d.ts +1 -0
  49. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  50. package/dist/wirings/http/web-request.js +32 -0
  51. package/dist/wirings/rpc/rpc-runner.d.ts +3 -2
  52. package/dist/wirings/rpc/rpc-runner.js +45 -11
  53. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  54. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  55. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  56. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  57. package/dist/wirings/workflow/index.d.ts +1 -1
  58. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  59. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  60. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  61. package/package.json +1 -1
  62. package/run-tests.sh +4 -1
  63. package/src/dev/hot-reload.test.ts +62 -11
  64. package/src/dev/hot-reload.ts +28 -7
  65. package/src/errors/error-handler.ts +8 -2
  66. package/src/errors/error.test.ts +2 -0
  67. package/src/function/function-runner.test.ts +500 -10
  68. package/src/function/function-runner.ts +68 -3
  69. package/src/function/functions.types.ts +3 -2
  70. package/src/index.ts +22 -3
  71. package/src/middleware/index.ts +11 -2
  72. package/src/middleware/telemetry.ts +2 -2
  73. package/src/middleware-runner.test.ts +16 -16
  74. package/src/middleware-runner.ts +42 -30
  75. package/src/permissions.test.ts +27 -24
  76. package/src/permissions.ts +41 -25
  77. package/src/pikku-request.test.ts +35 -0
  78. package/src/pikku-request.ts +1 -1
  79. package/src/pikku-state.ts +2 -3
  80. package/src/run-tests-script.test.ts +18 -0
  81. package/src/services/ai-agent-runner-service.ts +1 -0
  82. package/src/services/content-service.ts +79 -51
  83. package/src/services/in-memory-queue-service.ts +1 -1
  84. package/src/services/in-memory-session-store.ts +1 -2
  85. package/src/services/in-memory-workflow-service.test.ts +33 -11
  86. package/src/services/in-memory-workflow-service.ts +49 -13
  87. package/src/services/index.ts +10 -1
  88. package/src/services/local-content.test.ts +54 -0
  89. package/src/services/local-content.ts +80 -53
  90. package/src/services/typed-credential-service.ts +3 -3
  91. package/src/services/typed-secret-service.ts +3 -3
  92. package/src/services/typed-variables-service.ts +3 -3
  93. package/src/services/user-session-service.ts +4 -4
  94. package/src/testing/service-tests.ts +30 -0
  95. package/src/types/core.types.ts +6 -2
  96. package/src/types/state.types.ts +2 -13
  97. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  98. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  99. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  100. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  101. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  102. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  103. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  104. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  105. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  106. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  107. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  108. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  109. package/src/wirings/channel/channel-handler.test.ts +272 -0
  110. package/src/wirings/channel/channel-handler.ts +1 -1
  111. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  112. package/src/wirings/channel/channel-store.ts +19 -0
  113. package/src/wirings/channel/channel.types.ts +4 -0
  114. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  115. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  116. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  117. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  118. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  119. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  120. package/src/wirings/cli/cli-runner.test.ts +255 -2
  121. package/src/wirings/cli/cli-runner.ts +31 -10
  122. package/src/wirings/cli/cli.types.ts +4 -8
  123. package/src/wirings/cli/command-parser.test.ts +83 -0
  124. package/src/wirings/cli/command-parser.ts +23 -4
  125. package/src/wirings/http/http-runner.test.ts +296 -1
  126. package/src/wirings/http/http-runner.ts +87 -57
  127. package/src/wirings/http/http.types.ts +11 -26
  128. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  129. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  130. package/src/wirings/http/web-request.test.ts +115 -0
  131. package/src/wirings/http/web-request.ts +43 -5
  132. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  133. package/src/wirings/queue/queue.types.ts +2 -5
  134. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  135. package/src/wirings/rpc/rpc-runner.ts +60 -21
  136. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  137. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  138. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  139. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  140. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  141. package/src/wirings/workflow/index.ts +1 -0
  142. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  143. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  144. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  145. package/src/wirings/workflow/workflow.types.ts +68 -5
  146. package/tsconfig.tsbuildinfo +1 -1
@@ -2,12 +2,13 @@ import { pikkuState, getSingletonServices } from '../../pikku-state.js';
2
2
  import { AIProviderNotConfiguredError } from '../../errors/errors.js';
3
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
4
4
  import { randomUUID } from 'crypto';
5
- import { parseWorkingMemory, deepMergeWorkingMemory, resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
5
+ import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
6
6
  import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
7
7
  import { resolveModelConfig } from './ai-agent-model-config.js';
8
8
  function createPersistingChannel(parent, storage, threadId) {
9
9
  let fullText = '';
10
10
  let stepText = '';
11
+ let stepGenerativeUI = null;
11
12
  let stepToolCalls = [];
12
13
  let stepToolResults = [];
13
14
  const totalUsage = {
@@ -18,16 +19,23 @@ function createPersistingChannel(parent, storage, threadId) {
18
19
  if (!storage)
19
20
  return;
20
21
  const text = stepText;
22
+ const generativeUI = stepGenerativeUI;
21
23
  const calls = stepToolCalls;
22
24
  const results = stepToolResults;
23
25
  stepText = '';
26
+ stepGenerativeUI = null;
24
27
  stepToolCalls = [];
25
28
  stepToolResults = [];
26
- if (text || calls.length > 0) {
29
+ if (text || generativeUI != null || calls.length > 0) {
27
30
  const assistantMsg = {
28
31
  id: randomUUID(),
29
32
  role: 'assistant',
30
- content: text || undefined,
33
+ content: generativeUI != null
34
+ ? [
35
+ ...(text ? [{ type: 'text', text }] : []),
36
+ { type: 'generative-ui', spec: generativeUI },
37
+ ]
38
+ : text || undefined,
31
39
  toolCalls: calls.length > 0 ? calls : undefined,
32
40
  createdAt: new Date(),
33
41
  };
@@ -81,6 +89,9 @@ function createPersistingChannel(parent, storage, threadId) {
81
89
  : JSON.stringify(event.result),
82
90
  });
83
91
  break;
92
+ case 'generative-ui':
93
+ stepGenerativeUI = event.spec;
94
+ break;
84
95
  case 'usage':
85
96
  totalUsage.inputTokens += event.tokens.input;
86
97
  totalUsage.outputTokens += event.tokens.output;
@@ -95,10 +106,13 @@ function createPersistingChannel(parent, storage, threadId) {
95
106
  }
96
107
  parent.send(event);
97
108
  },
109
+ setState: (s) => parent.setState(s),
110
+ getState: () => parent.getState(),
111
+ clearState: () => parent.clearState(),
98
112
  };
99
113
  return channel;
100
114
  }
101
- async function postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, storage, memoryConfig, threadId, workingMemorySchemaName, messages, aiRunState, runId) {
115
+ async function postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, messages, aiRunState, runId) {
102
116
  const usage = persistingChannel.totalUsage;
103
117
  let outputText = persistingChannel.fullText;
104
118
  let outputMessages = messages;
@@ -117,22 +131,6 @@ async function postStreamCleanup(persistingChannel, aiMiddlewares, singletonServ
117
131
  outputMessages = result.messages;
118
132
  }
119
133
  }
120
- if (storage && memoryConfig?.workingMemory && outputText) {
121
- const parsed = parseWorkingMemory(outputText);
122
- if (parsed) {
123
- const existing = (await storage.getWorkingMemory(threadId, 'thread')) ?? {};
124
- const merged = deepMergeWorkingMemory(existing, parsed);
125
- if (singletonServices.schema && workingMemorySchemaName) {
126
- try {
127
- await singletonServices.schema.validateSchema(workingMemorySchemaName, merged);
128
- }
129
- catch (err) {
130
- singletonServices.logger.warn(`Working memory validation failed: ${err instanceof Error ? err.message : String(err)}`);
131
- }
132
- }
133
- await storage.saveWorkingMemory(threadId, 'thread', merged);
134
- }
135
- }
136
134
  await aiRunState.updateRun(runId, {
137
135
  status: 'completed',
138
136
  ...(usage.model
@@ -147,7 +145,7 @@ async function postStreamCleanup(persistingChannel, aiMiddlewares, singletonServ
147
145
  });
148
146
  }
149
147
  async function runStreamStepLoop(params) {
150
- const { agent, runnerParams, maxSteps, agentRunner, persistingChannel, channel, runId, aiMiddlewares, } = params;
148
+ const { agent, runnerParams, maxSteps, agentRunner, streamChannel, channel, runId, aiMiddlewares, } = params;
151
149
  const singletonServices = getSingletonServices();
152
150
  for (let step = 0; step < maxSteps; step++) {
153
151
  if (agent.prepareStep) {
@@ -166,7 +164,7 @@ async function runStreamStepLoop(params) {
166
164
  break;
167
165
  }
168
166
  channel.send({ type: 'step-start', stepNumber: step });
169
- const stepResult = await agentRunner.stream(runnerParams, persistingChannel);
167
+ const stepResult = await agentRunner.stream(runnerParams, streamChannel);
170
168
  for (const mw of aiMiddlewares) {
171
169
  if (mw.afterStep) {
172
170
  await mw.afterStep(singletonServices, {
@@ -246,10 +244,21 @@ export function checkForCredentialRequests(stepResult, runId) {
246
244
  return requests;
247
245
  }
248
246
  export function appendStepMessages(runnerParams, stepResult) {
247
+ const structuredOutput = stepResult.object && typeof stepResult.object === 'object'
248
+ ? stepResult.object
249
+ : null;
250
+ const assistantContent = structuredOutput?.ui != null
251
+ ? [
252
+ ...(stepResult.text
253
+ ? [{ type: 'text', text: stepResult.text }]
254
+ : []),
255
+ { type: 'generative-ui', spec: structuredOutput.ui },
256
+ ]
257
+ : stepResult.text || undefined;
249
258
  const assistantMsg = {
250
259
  id: randomUUID(),
251
260
  role: 'assistant',
252
- content: stepResult.text || undefined,
261
+ content: assistantContent,
253
262
  toolCalls: stepResult.toolCalls.length > 0
254
263
  ? stepResult.toolCalls.map((tc) => ({
255
264
  id: tc.toolCallId,
@@ -257,6 +266,9 @@ export function appendStepMessages(runnerParams, stepResult) {
257
266
  args: tc.args,
258
267
  }))
259
268
  : undefined,
269
+ ...(stepResult.reasoningContent
270
+ ? { reasoningContent: stepResult.reasoningContent }
271
+ : {}),
260
272
  createdAt: new Date(),
261
273
  };
262
274
  runnerParams.messages.push(assistantMsg);
@@ -363,7 +375,15 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
363
375
  channel.send({ type: 'done' });
364
376
  return '';
365
377
  }
366
- const aiMiddlewares = agent.aiMiddleware ?? [];
378
+ const aiMiddlewares = [
379
+ ...getWorkingMemoryMiddleware(memoryConfig, storage, {
380
+ threadId,
381
+ workingMemorySchemaName,
382
+ logger: singletonServices.logger,
383
+ schemaService: singletonServices.schema,
384
+ }),
385
+ ...(agent.aiMiddleware ?? []),
386
+ ];
367
387
  let modifiedMessages = runnerParams.messages;
368
388
  let modifiedInstructions = runnerParams.instructions;
369
389
  for (const mw of aiMiddlewares) {
@@ -422,9 +442,10 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
422
442
  ...streamMiddleware,
423
443
  ],
424
444
  });
445
+ const persistingChannel = createPersistingChannel(channel, storage, threadId);
425
446
  const wrappedChannel = allChannelMiddleware.length > 0
426
- ? wrapChannelWithMiddleware({ channel }, singletonServices, allChannelMiddleware).channel
427
- : channel;
447
+ ? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
448
+ : persistingChannel;
428
449
  // In delegate mode (default), suppress parent's text from reaching the client
429
450
  // AFTER a sub-agent has been called. If the parent responds directly (no delegation),
430
451
  // its text goes through normally. Sub-agent text bypasses this path entirely
@@ -446,13 +467,13 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
446
467
  delegateState,
447
468
  }
448
469
  : wrappedChannel;
449
- const persistingChannel = createPersistingChannel(outputChannel, storage, threadId);
450
470
  try {
451
471
  const loopResult = await runStreamStepLoop({
452
472
  agent,
453
473
  runnerParams,
454
474
  maxSteps,
455
475
  agentRunner,
476
+ streamChannel: outputChannel,
456
477
  persistingChannel,
457
478
  channel,
458
479
  runId,
@@ -466,7 +487,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
466
487
  await handleCredentialRequests(loopResult.credentialRequests, runId, channel, aiRunState, persistingChannel);
467
488
  return persistingChannel.fullText;
468
489
  }
469
- await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, storage, memoryConfig, threadId, workingMemorySchemaName, runnerParams.messages, aiRunState, runId);
490
+ await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, runnerParams.messages, aiRunState, runId);
470
491
  channel.send({ type: 'done' });
471
492
  channel.close();
472
493
  return persistingChannel.fullText;
@@ -683,7 +704,15 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
683
704
  const allMessages = [...contextMessages, ...messages];
684
705
  const trimmedMessages = trimMessages(allMessages);
685
706
  const instructions = await buildInstructions(resolvedName, packageName);
686
- const aiMiddlewares = agent.aiMiddleware ?? [];
707
+ const aiMiddlewares = [
708
+ ...getWorkingMemoryMiddleware(memoryConfig, storage, {
709
+ threadId: run.threadId,
710
+ workingMemorySchemaName,
711
+ logger: singletonServices.logger,
712
+ schemaService: singletonServices.schema,
713
+ }),
714
+ ...(agent.aiMiddleware ?? []),
715
+ ];
687
716
  let modifiedMessages = trimmedMessages;
688
717
  let modifiedInstructions = instructions;
689
718
  for (const mw of aiMiddlewares) {
@@ -726,14 +755,13 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
726
755
  ...streamMiddleware,
727
756
  ],
728
757
  });
758
+ const persistingChannel = createPersistingChannel(channel, storage, run.threadId);
729
759
  const wrappedChannel = allChannelMiddleware.length > 0
730
- ? wrapChannelWithMiddleware({ channel }, singletonServices, allChannelMiddleware).channel
731
- : channel;
732
- const persistingChannel = createPersistingChannel(wrappedChannel, storage, run.threadId);
760
+ ? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
761
+ : persistingChannel;
733
762
  const streamContext = { channel, options };
734
763
  const resumeTools = (await buildToolDefs(params, new Map(), run.resourceId, resolvedName, packageName, streamContext, aiMiddlewares)).tools;
735
764
  const resolved = resolveModelConfig(resolvedName, agent);
736
- console.log('[DEBUG resume] resolvedName:', resolvedName, 'agent.model:', agent.model, 'resolved.model:', resolved.model);
737
765
  const maxSteps = resolved.maxSteps ?? 10;
738
766
  const runnerParams = {
739
767
  model: resolved.model,
@@ -753,6 +781,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
753
781
  runnerParams,
754
782
  maxSteps,
755
783
  agentRunner,
784
+ streamChannel: wrappedChannel,
756
785
  persistingChannel,
757
786
  channel,
758
787
  runId: run.runId,
@@ -766,7 +795,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
766
795
  await handleCredentialRequests(loopResult.credentialRequests, run.runId, channel, aiRunState, persistingChannel);
767
796
  return;
768
797
  }
769
- await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, storage, memoryConfig, run.threadId, workingMemorySchemaName, runnerParams.messages, aiRunState, run.runId);
798
+ await postStreamCleanup(persistingChannel, aiMiddlewares, singletonServices, runnerParams.messages, aiRunState, run.runId);
770
799
  channel.send({ type: 'done' });
771
800
  channel.close();
772
801
  }
@@ -24,6 +24,13 @@ export type AIContentPart = {
24
24
  url?: string;
25
25
  mediaType: string;
26
26
  filename?: string;
27
+ } | {
28
+ type: 'data';
29
+ name: string;
30
+ data: unknown;
31
+ } | {
32
+ type: 'generative-ui';
33
+ spec: unknown;
27
34
  };
28
35
  export interface AIToolCall {
29
36
  id: string;
@@ -41,6 +48,7 @@ export interface AIMessage {
41
48
  content?: string | AIContentPart[];
42
49
  toolCalls?: AIToolCall[];
43
50
  toolResults?: AIToolResult[];
51
+ reasoningContent?: string;
44
52
  createdAt: Date;
45
53
  }
46
54
  export interface AIAgentStep {
@@ -298,6 +306,17 @@ export type AIStreamEvent = {
298
306
  graph: any;
299
307
  agent?: string;
300
308
  session?: string;
309
+ } | {
310
+ type: 'data';
311
+ name: string;
312
+ data: unknown;
313
+ agent?: string;
314
+ session?: string;
315
+ } | {
316
+ type: 'generative-ui';
317
+ spec: unknown;
318
+ agent?: string;
319
+ session?: string;
301
320
  } | {
302
321
  type: 'suspended';
303
322
  reason: 'rpc-missing';
@@ -364,6 +383,7 @@ export interface AgentRunRow {
364
383
  export interface AgentRunService {
365
384
  listThreads(options?: {
366
385
  agentName?: string;
386
+ resourceId?: string;
367
387
  limit?: number;
368
388
  offset?: number;
369
389
  }): Promise<AIThread[]>;
@@ -43,7 +43,7 @@ export const processMessageHandlers = (services, channelConfig, channelHandler,
43
43
  const routeMessage = routingProperty
44
44
  ? `route '${routingProperty}:${routerValue}'`
45
45
  : 'the default message route';
46
- logger.error(`Channel ${channelConfig.name} with id ${channelHandler.getChannel().channelId} requires a session for ${routeMessage}`);
46
+ logger.error(`Channel ${channelConfig.name} with id ${channelHandler.getChannel().channelId} requires a session for ${routeMessage}. No session is attached to this websocket connection. Ensure auth middleware establishes a session during websocket upgrade, and configure sessionStore when the runtime needs persisted sessions.`);
47
47
  // TODO: Send error message back breaks typescript, but should be implemented somehow
48
48
  channelHandler.getChannel().send(`Unauthorized for ${routeMessage}`);
49
49
  return;
@@ -13,4 +13,17 @@ export declare abstract class ChannelStore<ChannelType = unknown, OpeningData =
13
13
  }> | (TypedChannel & {
14
14
  pikkuUserId?: string;
15
15
  });
16
+ /**
17
+ * Persist per-socket scratch state scoped to a single channel (keyed by
18
+ * channelId). This is intentionally separate from `SessionStore` (which is
19
+ * keyed by `pikkuUserId` and holds the user session — shared across HTTP
20
+ * and channel transports).
21
+ *
22
+ * Use this for ephemeral, channel-local data: a per-socket subscription
23
+ * filter, a step in a connection-bound state machine, the last command sent
24
+ * on this socket, etc. It is cleared when the channel is removed.
25
+ */
26
+ abstract setState(channelId: string, state: unknown): Promise<void> | void;
27
+ abstract getState(channelId: string): Promise<unknown | undefined> | unknown | undefined;
28
+ abstract clearState(channelId: string): Promise<void> | void;
16
29
  }
@@ -80,6 +80,9 @@ export interface PikkuChannel<OpeningData, out Out> {
80
80
  sendBinary(data: BinaryData): Promise<void> | void;
81
81
  close(): Promise<void> | void;
82
82
  state: 'initial' | 'open' | 'closed';
83
+ setState<T>(state: T): Promise<void> | void;
84
+ getState<T>(): Promise<T | undefined> | T | undefined;
85
+ clearState(): Promise<void> | void;
83
86
  }
84
87
  export interface PikkuChannelHandler<OpeningData = unknown, Out = unknown> {
85
88
  send(message: Out, isBinary?: boolean): Promise<void> | void;
@@ -7,8 +7,26 @@ import { isProduction } from '../../../env.js';
7
7
  import { getErrorResponse } from '../../../errors/error-handler.js';
8
8
  import { handleHTTPError } from '../../../handle-error.js';
9
9
  import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../../services/user-session-service.js';
10
+ import { combineMiddleware, runMiddleware } from '../../../middleware-runner.js';
10
11
  import { runChannelLifecycleWithMiddleware } from '../channel-common.js';
11
- import { getSingletonServices, getCreateWireServices, } from '../../../pikku-state.js';
12
+ import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../../pikku-state.js';
13
+ const runUpgradeMiddleware = async (singletonServices, request, response, userSession) => {
14
+ const http = createHTTPWire(request, response);
15
+ const httpGroups = pikkuState(null, 'middleware', 'httpGroup');
16
+ const wireInheritedMiddleware = httpGroups['*']
17
+ ? [{ type: 'http', route: '*' }]
18
+ : undefined;
19
+ const middleware = combineMiddleware('channel', `upgrade:${request.path()}`, {
20
+ wireInheritedMiddleware,
21
+ });
22
+ if (middleware.length > 0) {
23
+ await runMiddleware(singletonServices, {
24
+ http,
25
+ ...createMiddlewareSessionWireProps(userSession),
26
+ }, middleware);
27
+ }
28
+ return http;
29
+ };
12
30
  export const runLocalChannel = async ({ channelId, request, response, route, skipUserSession = false, respondWith404 = true, coerceDataFromSchema = true, logWarningsForStatusCodes = [], bubbleErrors = false, }) => {
13
31
  const singletonServices = getSingletonServices();
14
32
  const createWireServices = getCreateWireServices();
@@ -17,12 +35,12 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
17
35
  let channelHandler;
18
36
  const userSession = new PikkuSessionService(singletonServices.sessionStore);
19
37
  let http;
20
- if (request) {
21
- http = createHTTPWire(request, response);
22
- route = http?.request?.path();
23
- }
24
38
  let openingData, channelConfig, meta;
25
39
  try {
40
+ if (request) {
41
+ http = await runUpgradeMiddleware(singletonServices, request, response, userSession);
42
+ route = request.path();
43
+ }
26
44
  ;
27
45
  ({ openingData, channelConfig, meta } = await openChannel({
28
46
  channelId,
@@ -10,6 +10,7 @@ export class PikkuAbstractChannelHandler {
10
10
  }
11
11
  getChannel() {
12
12
  if (!this.channel) {
13
+ let channelState;
13
14
  this.channel = {
14
15
  channelId: this.channelId,
15
16
  openingData: this.openingData,
@@ -17,6 +18,13 @@ export class PikkuAbstractChannelHandler {
17
18
  sendBinary: this.sendBinary.bind(this),
18
19
  close: this.close.bind(this),
19
20
  state: 'initial',
21
+ setState: (s) => {
22
+ channelState = s;
23
+ },
24
+ getState: () => channelState,
25
+ clearState: () => {
26
+ channelState = undefined;
27
+ },
20
28
  };
21
29
  }
22
30
  return this.channel;
@@ -54,6 +54,9 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
54
54
  channelHandlerFactory,
55
55
  channelName: channelConfig.name,
56
56
  });
57
+ channel.setState = (state) => channelStore.setState(channelId, state);
58
+ channel.getState = () => channelStore.getState(channelId);
59
+ channel.clearState = () => channelStore.clearState(channelId);
57
60
  const wire = {
58
61
  channel,
59
62
  ...createMiddlewareSessionWireProps(userSession),
@@ -116,6 +119,9 @@ export const runChannelDisconnect = async ({ channelId, channelStore, channelHan
116
119
  openingData,
117
120
  channelName,
118
121
  });
122
+ channel.setState = (state) => channelStore.setState(channelId, state);
123
+ channel.getState = () => channelStore.getState(channelId);
124
+ channel.clearState = () => channelStore.clearState(channelId);
119
125
  const userSession = new PikkuSessionService(singletonServices.sessionStore);
120
126
  if (pikkuUserId) {
121
127
  userSession.setPikkuUserId(pikkuUserId);
@@ -167,6 +173,9 @@ export const runChannelMessage = async ({ channelId, channelStore, channelHandle
167
173
  openingData,
168
174
  channelName,
169
175
  });
176
+ channel.setState = (state) => channelStore.setState(channelId, state);
177
+ channel.getState = () => channelStore.getState(channelId);
178
+ channel.clearState = () => channelStore.clearState(channelId);
170
179
  const userSession = new PikkuSessionService(singletonServices.sessionStore);
171
180
  if (pikkuUserId) {
172
181
  userSession.setPikkuUserId(pikkuUserId);
@@ -208,6 +208,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
208
208
  // Get the renderer
209
209
  const renderer = programData?.renderers[commandId] || programData?.defaultRenderer;
210
210
  // Create a CLI channel for progressive output
211
+ let cliState;
211
212
  const channel = {
212
213
  channelId: `cli:${program}:${commandId}`,
213
214
  openingData: pluckedData,
@@ -225,6 +226,13 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
225
226
  }
226
227
  },
227
228
  state: 'open',
229
+ setState: (s) => {
230
+ cliState = s;
231
+ },
232
+ getState: () => cliState,
233
+ clearState: () => {
234
+ cliState = undefined;
235
+ },
228
236
  };
229
237
  const userSession = new PikkuSessionService(singletonServices.sessionStore);
230
238
  const wire = {
@@ -252,10 +260,20 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
252
260
  sessionService: userSession,
253
261
  packageName: currentCommand.packageName,
254
262
  });
255
- // Apply renderer one final time with the final output (if renderer exists)
256
- // Skip if result is undefined (void functions handle their own output)
257
- if (renderer && result !== undefined) {
258
- await Promise.resolve(renderer(singletonServices, result, userSession.get()));
263
+ // Render the final result. `--json` / `--output json` routes the result
264
+ // through the global JSON renderer, but only for commands that opted into
265
+ // result-rendering by declaring a `render` — inline-printing commands (no
266
+ // per-command renderer) keep emitting their own output untouched.
267
+ // Skip if result is undefined (void functions handle their own output).
268
+ if (result !== undefined) {
269
+ const commandRenderer = programData?.renderers[commandId];
270
+ const jsonMode = data.json === true || data.output === 'json';
271
+ const finalRenderer = jsonMode && commandRenderer !== undefined
272
+ ? defaultJSONRenderer
273
+ : (commandRenderer ?? programData?.defaultRenderer);
274
+ if (finalRenderer !== undefined) {
275
+ await Promise.resolve(finalRenderer(singletonServices, result, userSession.get()));
276
+ }
259
277
  }
260
278
  return result;
261
279
  }
@@ -305,7 +323,8 @@ export async function executeCLI({ programName, args, createConfig, createSingle
305
323
  if (parsed.errors.length > 0) {
306
324
  // Check if any error is about an unknown command
307
325
  const hasUnknownCommand = parsed.errors.some((error) => error.startsWith('Unknown command:') ||
308
- error.startsWith('Command not found:'));
326
+ error.startsWith('Command not found:') ||
327
+ error.startsWith('Missing subcommand:'));
309
328
  if (hasUnknownCommand) {
310
329
  // Show help instead of error for unknown commands
311
330
  const helpText = generateCommandHelp(programName, allCLIMeta, parsed.commandPath);
@@ -2,6 +2,12 @@
2
2
  function toCamelCase(str) {
3
3
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
4
4
  }
5
+ /** Convert camelCase to kebab-case for display: "autoApply" → "auto-apply".
6
+ * Flags are stored camelCase (matching the function input field) but shown
7
+ * kebab; the parser accepts both forms via toCamelCase. */
8
+ function toKebabCase(str) {
9
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
10
+ }
5
11
  /**
6
12
  * Parses raw CLI arguments into structured data for a specific program
7
13
  */
@@ -77,6 +83,15 @@ export function parseCLIArguments(args, programName, allMeta) {
77
83
  }
78
84
  return result;
79
85
  }
86
+ // A group/parent command — has subcommands but no runnable function of its
87
+ // own — cannot be executed directly. Surface a routable error so the runner
88
+ // shows its subcommand help instead of attempting to run a missing function.
89
+ if (!commandMeta.pikkuFuncId &&
90
+ commandMeta.subcommands &&
91
+ Object.keys(commandMeta.subcommands).length > 0) {
92
+ result.errors.push(`Missing subcommand: ${result.commandPath.join(' ')}`);
93
+ return result;
94
+ }
80
95
  // Collect all available options (global + inherited)
81
96
  const availableOptions = collectAvailableOptions(meta, result.commandPath);
82
97
  // Parse remaining arguments as positionals and options
@@ -289,7 +304,7 @@ function applyOptionDefaults(optionDefs, options, result) {
289
304
  }
290
305
  // Check required
291
306
  if (def.required && !(name in options)) {
292
- result.errors.push(`Missing required option: --${name}`);
307
+ result.errors.push(`Missing required option: --${toKebabCase(name)}`);
293
308
  }
294
309
  // Validate choices
295
310
  if (def.choices && name in options) {
@@ -297,12 +312,12 @@ function applyOptionDefaults(optionDefs, options, result) {
297
312
  if (Array.isArray(value)) {
298
313
  for (const v of value) {
299
314
  if (!def.choices.includes(v)) {
300
- result.errors.push(`Invalid value for --${name}: ${v}. Valid choices: ${def.choices.join(', ')}`);
315
+ result.errors.push(`Invalid value for --${toKebabCase(name)}: ${v}. Valid choices: ${def.choices.join(', ')}`);
301
316
  }
302
317
  }
303
318
  }
304
319
  else if (!def.choices.includes(value)) {
305
- result.errors.push(`Invalid value for --${name}: ${value}. Valid choices: ${def.choices.join(', ')}`);
320
+ result.errors.push(`Invalid value for --${toKebabCase(name)}: ${value}. Valid choices: ${def.choices.join(', ')}`);
306
321
  }
307
322
  }
308
323
  }
@@ -390,7 +405,7 @@ function formatOptions(options, lines) {
390
405
  else {
391
406
  line += ' ';
392
407
  }
393
- line += `--${name}`;
408
+ line += `--${toKebabCase(name)}`;
394
409
  if (opt.default !== undefined && typeof opt.default !== 'boolean') {
395
410
  line += ` <value>`;
396
411
  }