@ema.co/mcp-toolkit 2026.2.27 → 2026.2.28

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.

Potentially problematic release.


This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/.context/public/guides/ema-user-guide.md +7 -6
  2. package/.context/public/guides/mcp-tools-guide.md +46 -23
  3. package/dist/config/index.js +11 -0
  4. package/dist/config/workflow-patterns.js +361 -0
  5. package/dist/mcp/autobuilder.js +2 -2
  6. package/dist/mcp/domain/generation-schema.js +15 -9
  7. package/dist/mcp/domain/structural-rules.js +3 -3
  8. package/dist/mcp/domain/validation-rules.js +20 -27
  9. package/dist/mcp/domain/workflow-generator.js +3 -3
  10. package/dist/mcp/domain/workflow-graph.js +1 -1
  11. package/dist/mcp/guidance.js +60 -1
  12. package/dist/mcp/handlers/conversation/adapter.js +13 -0
  13. package/dist/mcp/handlers/conversation/create.js +19 -0
  14. package/dist/mcp/handlers/conversation/delete.js +18 -0
  15. package/dist/mcp/handlers/conversation/formatters.js +62 -0
  16. package/dist/mcp/handlers/conversation/history.js +15 -0
  17. package/dist/mcp/handlers/conversation/index.js +43 -0
  18. package/dist/mcp/handlers/conversation/list.js +40 -0
  19. package/dist/mcp/handlers/conversation/messages.js +13 -0
  20. package/dist/mcp/handlers/conversation/rename.js +16 -0
  21. package/dist/mcp/handlers/conversation/send.js +90 -0
  22. package/dist/mcp/handlers/data/index.js +169 -3
  23. package/dist/mcp/handlers/feedback/client-id.js +49 -0
  24. package/dist/mcp/handlers/feedback/coalesce.js +167 -0
  25. package/dist/mcp/handlers/feedback/index.js +42 -1
  26. package/dist/mcp/handlers/feedback/outbox.js +301 -0
  27. package/dist/mcp/handlers/feedback/probes.js +127 -0
  28. package/dist/mcp/handlers/feedback/remote-store.js +59 -0
  29. package/dist/mcp/handlers/feedback/store.js +13 -1
  30. package/dist/mcp/handlers/persona/delete.js +7 -28
  31. package/dist/mcp/handlers/persona/update.js +7 -26
  32. package/dist/mcp/handlers/persona/version.js +30 -15
  33. package/dist/mcp/handlers/template/adapter.js +23 -0
  34. package/dist/mcp/handlers/template/crud.js +174 -0
  35. package/dist/mcp/handlers/template/index.js +6 -7
  36. package/dist/mcp/handlers/workflow/adapter.js +30 -46
  37. package/dist/mcp/handlers/workflow/index.js +2 -2
  38. package/dist/mcp/handlers/workflow/validation.js +2 -2
  39. package/dist/mcp/knowledge-guidance-topics.js +90 -53
  40. package/dist/mcp/knowledge.js +7 -357
  41. package/dist/mcp/prompts.js +5 -5
  42. package/dist/mcp/resources-dynamic.js +46 -38
  43. package/dist/mcp/resources-validation.js +5 -5
  44. package/dist/mcp/server.js +38 -5
  45. package/dist/mcp/tools.js +340 -8
  46. package/dist/sdk/client-adapter.js +90 -2
  47. package/dist/sdk/client.js +7 -0
  48. package/dist/sdk/ema-client.js +242 -27
  49. package/dist/sdk/generated/agent-catalog.js +96 -39
  50. package/dist/sdk/generated/deprecated-actions.js +1 -1
  51. package/dist/sdk/grpc-client.js +67 -5
  52. package/dist/sync/central-factory.js +86 -0
  53. package/dist/sync/central-version-storage.js +387 -0
  54. package/dist/sync/dis-port.js +75 -0
  55. package/dist/sync/version-policy.js +29 -31
  56. package/dist/sync/version-storage-interface.js +11 -0
  57. package/dist/sync/version-storage.js +22 -22
  58. package/package.json +2 -1
@@ -0,0 +1,90 @@
1
+ import { formatSendResponse } from "./formatters.js";
2
+ /**
3
+ * Build a ChatbotMessage proto-dict from convenience parameters.
4
+ *
5
+ * Supports three message types:
6
+ * - text: Simple text message or text HITL continuation
7
+ * - form: Form HITL continuation (filled fields or cancellation)
8
+ * - buttons: Button HITL continuation (selected button)
9
+ *
10
+ * If none of the convenience params are provided, falls back to `raw_message`
11
+ * which must be a pre-built ChatbotMessage dict with a valid `type` field.
12
+ */
13
+ function buildChatbotMessage(args) {
14
+ const text = args.text;
15
+ const form = args.form;
16
+ const buttons = args.buttons;
17
+ const rawMessage = args.raw_message;
18
+ if (text !== undefined) {
19
+ return {
20
+ type: "MESSAGE_TYPE_TEXT",
21
+ textMessage: { contents: [text] },
22
+ isUserMessage: true,
23
+ };
24
+ }
25
+ if (buttons !== undefined) {
26
+ return {
27
+ type: "MESSAGE_TYPE_BUTTONS",
28
+ buttonsMessage: {
29
+ selectedButton: buttons.selected_button ?? buttons.selectedButton,
30
+ },
31
+ isUserMessage: true,
32
+ };
33
+ }
34
+ if (form !== undefined) {
35
+ return {
36
+ type: "MESSAGE_TYPE_FORM",
37
+ formMessage: {
38
+ fields: form.fields,
39
+ isCancelled: form.is_cancelled ?? form.isCancelled ?? false,
40
+ ...(form.content !== undefined && { content: form.content }),
41
+ },
42
+ isUserMessage: true,
43
+ };
44
+ }
45
+ if (rawMessage !== undefined) {
46
+ // Validate raw_message has a type field
47
+ if (!rawMessage.type || typeof rawMessage.type !== "string") {
48
+ return null; // Will trigger the "no content" error with a specific hint
49
+ }
50
+ return rawMessage;
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Send a message in an existing conversation.
56
+ */
57
+ export async function handleSend(args, client) {
58
+ const conversationId = args.conversation_id;
59
+ if (!conversationId) {
60
+ return {
61
+ error: "conversation_id is required",
62
+ hint: 'conversation(method="send", conversation_id="<uuid>", text="hello")',
63
+ };
64
+ }
65
+ const message = buildChatbotMessage(args);
66
+ if (!message) {
67
+ // Distinguish between raw_message validation failure and no content at all
68
+ if (args.raw_message) {
69
+ return {
70
+ error: "raw_message must include a 'type' field (e.g. 'MESSAGE_TYPE_TEXT')",
71
+ hint: 'raw_message={type:"MESSAGE_TYPE_TEXT", textMessage:{contents:["hello"]}, isUserMessage:true}',
72
+ };
73
+ }
74
+ return {
75
+ error: "No message content provided. Supply one of: text, form, buttons, or raw_message",
76
+ hint: 'conversation(method="send", conversation_id="...", text="your message")',
77
+ examples: [
78
+ 'text="hello" - send a text message',
79
+ 'buttons={selected_button:{label:"Yes",description:"Confirm"}} - select a button',
80
+ 'raw_message={type:"MESSAGE_TYPE_FORM", formMessage:{...}} - submit a form (echo back formMessage with values)',
81
+ 'form={is_cancelled:true} - cancel a form',
82
+ ],
83
+ };
84
+ }
85
+ const result = await client.sendMessage(conversationId, message, {
86
+ userContext: args.user_context,
87
+ originalMessageId: args.original_message_id,
88
+ });
89
+ return formatSendResponse(result);
90
+ }
@@ -18,6 +18,7 @@
18
18
  * - regenerate: Modify document section with new query
19
19
  * - replace: Replace entire document content (full replacement, not partial)
20
20
  * - sanitize: Sanitize data items for a persona
21
+ * - continue: Respond to HITL pause on a dashboard row (text, button, or form)
21
22
  *
22
23
  * Note: Schema is now at persona level: persona(method="schema", id="...")
23
24
  */
@@ -335,7 +336,11 @@ export async function handleData(args, client, readFile) {
335
336
  persona_id: personaId,
336
337
  uploaded_rows: results.length,
337
338
  row_ids: results.map(r => r.row_id),
338
- _tip: "Dashboard rows created with file attachments trigger workflow execution automatically",
339
+ _tip: "Rows created and workflow triggered. Poll for completion using method='result' with each row_id.",
340
+ _next_step: results.length === 1
341
+ ? `persona(id="${personaId}", data={method:"result", row_id:"${results[0].row_id}"})`
342
+ : `Poll each row: ${results.map(r => `persona(id="${personaId}", data={method:"result", row_id:"${r.row_id}"})`).join(", ")}`,
343
+ _poll_interval_seconds: 5,
339
344
  };
340
345
  }
341
346
  catch (error) {
@@ -459,10 +464,40 @@ export async function handleData(args, client, readFile) {
459
464
  return { error: `Failed to replace document: ${error instanceof Error ? error.message : String(error)}` };
460
465
  }
461
466
  }
467
+ case "result": {
468
+ // Get dashboard row result/output
469
+ const rowId = (dataArgs?.row_id ?? dataArgs?.id);
470
+ if (!rowId) {
471
+ return errorResult("data.result requires row_id parameter", {
472
+ hint: "Get row_id from the upload response: persona(id='abc', data={method:'upload', items:[...]})",
473
+ });
474
+ }
475
+ const includeFileContents = (dataArgs?.include_file_contents ?? dataArgs?.include_files);
476
+ return handleDashboardResult(personaId, rowId, includeFileContents ?? false, client);
477
+ }
478
+ case "continue": {
479
+ // Respond to HITL pause on a dashboard row
480
+ const rowId = (dataArgs?.row_id ?? dataArgs?.id);
481
+ if (!rowId) {
482
+ return errorResult("data.continue requires row_id parameter", {
483
+ hint: "Get row_id from the result response when requires_human_input=true",
484
+ });
485
+ }
486
+ const text = dataArgs?.text;
487
+ const buttons = dataArgs?.buttons;
488
+ const form = dataArgs?.form;
489
+ const rawContinuation = dataArgs?.raw_continuation;
490
+ if (!text && !buttons && !form && !rawContinuation) {
491
+ return errorResult("data.continue requires one of: text, buttons, form, or raw_continuation", {
492
+ hint: "text='approved' for free-text, buttons={selected_button:{label:'Approve'}} for button selection, form={is_cancelled:true} for form cancellation, raw_continuation={formMessage:{...full form with values...}} for form submission",
493
+ });
494
+ }
495
+ return handleDashboardContinue(personaId, rowId, { text, buttons, form, raw: rawContinuation }, client);
496
+ }
462
497
  default:
463
498
  return {
464
499
  error: `Unknown data method: ${method}`,
465
- hint: "Valid methods: list, stats, templates, generate, get, copy, replicate, upload, delete, embedding, refresh, regenerate, replace, sanitize",
500
+ hint: "Valid methods: list, stats, templates, generate, get, copy, replicate, upload, delete, embed, refresh, result, continue, regenerate, replace, sanitize",
466
501
  };
467
502
  }
468
503
  }
@@ -684,6 +719,137 @@ async function handleDashboardRefresh(personaId, rowId, client) {
684
719
  return { error: `Failed to refresh dashboard row: ${error instanceof Error ? error.message : String(error)}` };
685
720
  }
686
721
  }
722
+ /**
723
+ * Detect the type of continuation message (text, buttons, or form).
724
+ * Mirrors how chat's formatSendResponse detects MESSAGE_TYPE_BUTTONS/FORM/TEXT.
725
+ */
726
+ function detectContinuationType(continuation) {
727
+ if (!continuation)
728
+ return "unknown";
729
+ // Check the oneof field that's populated in the ContinuationMessage proto
730
+ if (continuation.buttonsMessage)
731
+ return "buttons";
732
+ if (continuation.formMessage)
733
+ return "form";
734
+ if (continuation.textMessage)
735
+ return "text";
736
+ // Fallback: check for a `value` wrapper (some proto serializations)
737
+ const value = continuation.value;
738
+ if (value?.case === "buttonsMessage")
739
+ return "buttons";
740
+ if (value?.case === "formMessage")
741
+ return "form";
742
+ if (value?.case === "textMessage")
743
+ return "text";
744
+ return "unknown";
745
+ }
746
+ /**
747
+ * Get the result/output of a dashboard row.
748
+ * Detects row state and provides polling/debugging guidance.
749
+ */
750
+ async function handleDashboardResult(personaId, rowId, includeFileContents, client) {
751
+ try {
752
+ const result = await client.getDashboardRowResult(personaId, rowId, includeFileContents);
753
+ const row = result.row;
754
+ const state = row?.state ?? "unknown";
755
+ const isComplete = state === "DASHBOARD_ROW_STATE_SUCCESS";
756
+ const isFailed = state === "DASHBOARD_ROW_STATE_FAILED";
757
+ const isReviewing = state === "DASHBOARD_ROW_STATE_REVIEWING";
758
+ const isRunning = [
759
+ "DASHBOARD_ROW_STATE_RUNNING",
760
+ "DASHBOARD_ROW_STATE_QUEUED",
761
+ "DASHBOARD_ROW_STATE_INITIAL",
762
+ ].includes(state);
763
+ const response = {
764
+ method: "result",
765
+ persona_id: personaId,
766
+ row_id: rowId,
767
+ state,
768
+ is_complete: isComplete,
769
+ row: result.row,
770
+ };
771
+ if (result.additional_column_details?.length) {
772
+ response.additional_column_details = result.additional_column_details;
773
+ }
774
+ if (isReviewing) {
775
+ // HITL — workflow is paused waiting for human input/approval
776
+ response.requires_human_input = true;
777
+ const continuation = (row?.continuationMessage ?? row?.continuation_message);
778
+ if (continuation) {
779
+ response.continuation_message = continuation;
780
+ }
781
+ // Detect continuation type for type-specific guidance (matches chat's formatSendResponse pattern)
782
+ const contType = detectContinuationType(continuation);
783
+ response.continuation_type = contType;
784
+ if (contType === "buttons") {
785
+ const buttonsMsg = continuation?.buttonsMessage;
786
+ const buttons = buttonsMsg?.buttons;
787
+ const labels = buttons?.map(b => b.label).filter(Boolean) ?? [];
788
+ response._tip = `HITL: The workflow is asking the user to select a button.${labels.length ? ` Options: ${labels.join(", ")}` : ""} Ask the user which to choose.`;
789
+ response._next_step = `persona(id="${personaId}", data={method:"continue", row_id:"${rowId}", buttons:{selected_button:{label:"<chosen label>"}}})`;
790
+ }
791
+ else if (contType === "form") {
792
+ const formMsg = continuation?.formMessage;
793
+ const isIntermediate = formMsg?.isIntermediate === true;
794
+ response._tip = `HITL: The workflow is asking the user to fill out a form.${isIntermediate ? " This is an INTERMEDIATE form — more fields will follow after submission." : ""} Review the fields in continuation_message.formMessage, ask the user for values, then echo back the full formMessage with values filled in using raw_continuation. Use form={is_cancelled:true} to cancel instead.`;
795
+ response._next_step = `persona(id="${personaId}", data={method:"continue", row_id:"${rowId}", raw_continuation:{formMessage:{...continuation_message.formMessage with field values filled in...}}})`;
796
+ response._form_value_guide = "Field values use wellKnown types: {wellKnown:{stringValue:'text'}} for strings, {wellKnown:{int64Value:'42'}} for ints (string-encoded), {wellKnown:{doubleValue:3.14}} for floats, {wellKnown:{boolValue:true}} for bools, {wellKnown:{dateValue:{year,month,day}}} for dates, {wellKnown:{datetimeValue:{utcTime:'ISO'}}} for datetimes. For struct dropdowns use Id as stringValue; for enum dropdowns use enumValue as stringValue.";
797
+ }
798
+ else {
799
+ response._tip = "HITL: Workflow is paused and waiting for human input. Review the continuation_message and ask the user what to do.";
800
+ response._next_step = `persona(id="${personaId}", data={method:"continue", row_id:"${rowId}", text:"user's response"})`;
801
+ }
802
+ }
803
+ else if (isRunning) {
804
+ response._tip = "Row is still processing. Poll again in 5-10 seconds.";
805
+ response._next_step = `persona(id="${personaId}", data={method:"result", row_id:"${rowId}"})`;
806
+ response._poll_interval_seconds = 5;
807
+ }
808
+ else if (isFailed) {
809
+ response._tip = "Row processing failed. Check workflow execution for details.";
810
+ const workflowRunId = row?.workflowRunId ?? row?.workflow_run_id;
811
+ if (workflowRunId) {
812
+ response._next_step = `debug(method="show_work", persona_id="${personaId}", workflow_run_id="${workflowRunId}")`;
813
+ }
814
+ }
815
+ else if (isComplete && !includeFileContents) {
816
+ response._tip = "Row complete. Add include_file_contents=true to get generated file contents.";
817
+ }
818
+ return response;
819
+ }
820
+ catch (error) {
821
+ return {
822
+ error: `Failed to get dashboard row result: ${error instanceof Error ? error.message : String(error)}`,
823
+ persona_id: personaId,
824
+ row_id: rowId,
825
+ };
826
+ }
827
+ }
828
+ /**
829
+ * Continue a paused (HITL) dashboard row with a response.
830
+ * Sends text, button selection, or form data to resume the workflow.
831
+ */
832
+ async function handleDashboardContinue(personaId, rowId, continuation, client) {
833
+ try {
834
+ await client.continueDashboardRow(rowId, continuation);
835
+ return {
836
+ method: "continue",
837
+ persona_id: personaId,
838
+ row_id: rowId,
839
+ sent: continuation.raw ? "raw" : continuation.text ? "text" : continuation.buttons ? "buttons" : "form",
840
+ _tip: "Continuation sent. The workflow is resuming. Poll for the result.",
841
+ _next_step: `persona(id="${personaId}", data={method:"result", row_id:"${rowId}"})`,
842
+ _poll_interval_seconds: 5,
843
+ };
844
+ }
845
+ catch (error) {
846
+ return {
847
+ error: `Failed to continue dashboard row: ${error instanceof Error ? error.message : String(error)}`,
848
+ persona_id: personaId,
849
+ row_id: rowId,
850
+ };
851
+ }
852
+ }
687
853
  // ─────────────────────────────────────────────────────────────────────────────
688
854
  // Method inference and clarification when method is omitted
689
855
  // ─────────────────────────────────────────────────────────────────────────────
@@ -733,7 +899,7 @@ function inferMethodAndClarify(args, dataArgs) {
733
899
  return {
734
900
  status: "clarification_needed",
735
901
  message: "What operation would you like to perform?",
736
- options: ["list", "upload", "generate", "templates", "get", "delete", "copy", "replicate", "embedding", "sanitize", "refresh", "regenerate", "replace"],
902
+ options: ["list", "upload", "generate", "templates", "get", "delete", "copy", "replicate", "embedding", "sanitize", "refresh", "result", "continue", "regenerate", "replace"],
737
903
  hint: "Specify method='...' for direct execution. Example: data(persona_id='abc', method='list')",
738
904
  };
739
905
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client Identity — Persistent anonymous client ID for feedback correlation.
3
+ *
4
+ * Generates a random UUID on first use and stores it at ~/.ema-mcp/client-id.
5
+ * This survives npm reinstalls, npx cache clears, and toolkit version bumps
6
+ * because it lives in the user's home directory, not in node_modules.
7
+ *
8
+ * No PII — just a random UUID for cross-session correlation.
9
+ */
10
+ import { promises as fs } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { randomUUID } from "node:crypto";
14
+ const EMA_MCP_DIR = join(homedir(), ".ema-mcp");
15
+ const CLIENT_ID_PATH = join(EMA_MCP_DIR, "client-id");
16
+ let cachedClientId = null;
17
+ /**
18
+ * Get or create a persistent anonymous client ID.
19
+ * Cached in memory after first read — no repeated disk I/O.
20
+ */
21
+ export async function getOrCreateClientId() {
22
+ if (cachedClientId)
23
+ return cachedClientId;
24
+ try {
25
+ const existing = await fs.readFile(CLIENT_ID_PATH, "utf-8");
26
+ const trimmed = existing.trim();
27
+ if (trimmed.length > 0) {
28
+ cachedClientId = trimmed;
29
+ return trimmed;
30
+ }
31
+ }
32
+ catch {
33
+ // File doesn't exist yet — will create below
34
+ }
35
+ const id = randomUUID();
36
+ try {
37
+ await fs.mkdir(dirname(CLIENT_ID_PATH), { recursive: true });
38
+ await fs.writeFile(CLIENT_ID_PATH, id + "\n", "utf-8");
39
+ }
40
+ catch {
41
+ // Best-effort persistence — if we can't write, use ephemeral ID for this session
42
+ }
43
+ cachedClientId = id;
44
+ return id;
45
+ }
46
+ /** The base directory for all MCP client-local state (~/.ema-mcp/). */
47
+ export function getEmaMcpDir() {
48
+ return EMA_MCP_DIR;
49
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Feedback Coalescing — Transform raw outbox entries into compact FeedbackDigest.
3
+ *
4
+ * Raw feedback and telemetry entries are accumulated locally. Before uploading,
5
+ * this module compresses them into a single digest that preserves signal while
6
+ * minimizing wire size.
7
+ *
8
+ * - Feedback: deduplicated by (category, message_prefix, tool, operation)
9
+ * - Telemetry: aggregated by (tool, operation) with error counts and latency stats
10
+ * - Resources: counted by URI
11
+ * - Probes: passed through as-is
12
+ */
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Build Digest
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ const MAX_MESSAGE_LEN = 200;
17
+ const MAX_TOP_ERRORS = 3;
18
+ export function buildDigest(entries, clientId, toolkitVersion) {
19
+ const feedbackEntries = [];
20
+ const telemetryEntries = [];
21
+ const probeResponses = [];
22
+ for (const entry of entries) {
23
+ switch (entry.kind) {
24
+ case "feedback":
25
+ feedbackEntries.push(entry.data);
26
+ break;
27
+ case "telemetry":
28
+ telemetryEntries.push(entry.data);
29
+ break;
30
+ case "probe_response":
31
+ probeResponses.push(entry.data);
32
+ break;
33
+ }
34
+ }
35
+ const allTimestamps = [
36
+ ...feedbackEntries.map((e) => e.ts),
37
+ ...telemetryEntries.map((e) => e.ts),
38
+ ...probeResponses.map((e) => e.ts),
39
+ ].sort();
40
+ const now = new Date().toISOString();
41
+ return {
42
+ schema_version: 1,
43
+ client_id: clientId,
44
+ toolkit_version: toolkitVersion,
45
+ flushed_at: now,
46
+ period: {
47
+ from: allTimestamps[0] ?? now,
48
+ to: allTimestamps[allTimestamps.length - 1] ?? now,
49
+ },
50
+ entry_count: entries.length,
51
+ feedback: deduplicateFeedback(feedbackEntries),
52
+ telemetry: coalesceTelemetry(telemetryEntries),
53
+ resource_usage: extractResourceUsage(telemetryEntries),
54
+ probe_responses: probeResponses.length > 0 ? probeResponses : undefined,
55
+ probes_shown: undefined, // Set by caller if probe tracking is available
56
+ };
57
+ }
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // Feedback Deduplication
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ function deduplicateFeedback(entries) {
62
+ const groups = new Map();
63
+ for (const e of entries) {
64
+ const msgPrefix = truncate(e.message, MAX_MESSAGE_LEN);
65
+ const key = `${e.category}|${msgPrefix}|${e.tool ?? ""}|${e.operation ?? ""}`;
66
+ const existing = groups.get(key);
67
+ if (existing) {
68
+ existing.count++;
69
+ if (e.ts < existing.first_seen)
70
+ existing.first_seen = e.ts;
71
+ if (e.ts > existing.last_seen)
72
+ existing.last_seen = e.ts;
73
+ if (e.severity && severityRank(e.severity) > severityRank(existing.severity)) {
74
+ existing.severity = e.severity;
75
+ }
76
+ }
77
+ else {
78
+ groups.set(key, {
79
+ category: e.category,
80
+ message: msgPrefix,
81
+ tool: e.tool,
82
+ operation: e.operation,
83
+ severity: e.severity,
84
+ count: 1,
85
+ first_seen: e.ts,
86
+ last_seen: e.ts,
87
+ });
88
+ }
89
+ }
90
+ return [...groups.values()].sort((a, b) => b.count - a.count);
91
+ }
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // Telemetry Aggregation
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ function coalesceTelemetry(entries) {
96
+ const groups = new Map();
97
+ for (const e of entries) {
98
+ if (e.type === "resource_fetch")
99
+ continue; // handled separately
100
+ const key = `${e.tool ?? "unknown"}:${e.op ?? ""}`;
101
+ let g = groups.get(key);
102
+ if (!g) {
103
+ g = { total: 0, errors: 0, latencies: [], errorMessages: new Map() };
104
+ groups.set(key, g);
105
+ }
106
+ g.total++;
107
+ if (!e.ok) {
108
+ g.errors++;
109
+ if (e.error_message) {
110
+ const normalized = truncate(e.error_message, 100);
111
+ g.errorMessages.set(normalized, (g.errorMessages.get(normalized) ?? 0) + 1);
112
+ }
113
+ }
114
+ if (e.ms != null) {
115
+ g.latencies.push(e.ms);
116
+ }
117
+ }
118
+ return [...groups.entries()].map(([key, g]) => {
119
+ const [tool, op] = key.split(":");
120
+ const agg = {
121
+ tool,
122
+ op: op || undefined,
123
+ total: g.total,
124
+ errors: g.errors,
125
+ };
126
+ if (g.latencies.length > 0) {
127
+ agg.avg_ms = Math.round(g.latencies.reduce((a, b) => a + b, 0) / g.latencies.length);
128
+ if (g.latencies.length >= 5) {
129
+ const sorted = [...g.latencies].sort((a, b) => a - b);
130
+ agg.p95_ms = sorted[Math.floor(sorted.length * 0.95)];
131
+ }
132
+ }
133
+ if (g.errorMessages.size > 0) {
134
+ agg.top_errors = [...g.errorMessages.entries()]
135
+ .sort((a, b) => b[1] - a[1])
136
+ .slice(0, MAX_TOP_ERRORS)
137
+ .map(([message, count]) => ({ message, count }));
138
+ }
139
+ return agg;
140
+ });
141
+ }
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // Resource Usage Extraction
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+ function extractResourceUsage(entries) {
146
+ const counts = new Map();
147
+ for (const e of entries) {
148
+ if (e.type === "resource_fetch" && e.resource_uri) {
149
+ counts.set(e.resource_uri, (counts.get(e.resource_uri) ?? 0) + 1);
150
+ }
151
+ }
152
+ if (counts.size === 0)
153
+ return undefined;
154
+ return [...counts.entries()]
155
+ .map(([uri, fetches]) => ({ uri, fetches }))
156
+ .sort((a, b) => b.fetches - a.fetches);
157
+ }
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Helpers
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+ function truncate(value, maxLen) {
162
+ return value.length > maxLen ? value.slice(0, maxLen) + "..." : value;
163
+ }
164
+ const SEVERITY_RANK = { low: 1, medium: 2, high: 3 };
165
+ function severityRank(severity) {
166
+ return severity ? (SEVERITY_RANK[severity] ?? 0) : 0;
167
+ }
@@ -9,15 +9,21 @@
9
9
  * - list - View recent feedback entries
10
10
  * - analyze - Aggregate feedback + telemetry into actionable insights
11
11
  * - rotate - Manually trigger log rotation
12
+ * - flush - Force immediate outbox flush to remote storage
12
13
  */
13
14
  import { errorResult } from "../types.js";
14
15
  import { submitFeedback, listFeedback, listTelemetry, analyzeFeedback, rotateLogs, } from "./store.js";
16
+ import { markProbeResponded } from "./probes.js";
17
+ import { appendToOutbox, flushOutbox, getOutboxStats } from "./outbox.js";
18
+ import { isRemoteEnabled } from "./remote-store.js";
19
+ import { TOOLKIT_VERSION } from "../env/config.js";
15
20
  const VALID_CATEGORIES = [
16
21
  "gap",
17
22
  "confusion",
18
23
  "success",
19
24
  "error_unclear",
20
25
  "suggestion",
26
+ "probe_response",
21
27
  ];
22
28
  const VALID_SEVERITIES = ["low", "medium", "high"];
23
29
  /** Max lengths to prevent oversized entries from consuming disk/memory */
@@ -42,9 +48,11 @@ export async function handleFeedback(args) {
42
48
  return handleAnalyze();
43
49
  case "rotate":
44
50
  return handleRotate();
51
+ case "flush":
52
+ return handleFlush();
45
53
  default:
46
54
  return errorResult(`Unknown feedback method: ${method}`, {
47
- hint: "Available methods: submit, list, analyze, rotate",
55
+ hint: "Available methods: submit, list, analyze, rotate, flush",
48
56
  });
49
57
  }
50
58
  }
@@ -83,6 +91,20 @@ async function handleSubmit(args) {
83
91
  context: args.context ? truncate(String(args.context), MAX_CONTEXT_LENGTH) : undefined,
84
92
  severity: severity,
85
93
  });
94
+ // Track probe responses for the probe system
95
+ if (category === "probe_response" && args.context) {
96
+ const probeId = String(args.context);
97
+ markProbeResponded(probeId);
98
+ // Also mirror to outbox as a dedicated probe_response entry
99
+ const probeResponse = {
100
+ id: probeId,
101
+ response: truncate(message, MAX_MESSAGE_LENGTH),
102
+ ts: entry.ts,
103
+ };
104
+ if (isRemoteEnabled()) {
105
+ appendToOutbox({ kind: "probe_response", data: probeResponse }).catch(() => { });
106
+ }
107
+ }
86
108
  return {
87
109
  success: true,
88
110
  feedback_id: entry.id,
@@ -137,3 +159,22 @@ async function handleRotate() {
137
159
  message: "Log files rotated successfully.",
138
160
  };
139
161
  }
162
+ async function handleFlush() {
163
+ if (!isRemoteEnabled()) {
164
+ return {
165
+ success: false,
166
+ message: "Remote feedback collection is disabled (EMA_FEEDBACK_DISABLED=1).",
167
+ _tip: "Unset EMA_FEEDBACK_DISABLED to enable remote upload.",
168
+ };
169
+ }
170
+ const before = await getOutboxStats();
171
+ await flushOutbox(TOOLKIT_VERSION);
172
+ const after = await getOutboxStats();
173
+ return {
174
+ success: true,
175
+ message: `Outbox flushed. ${before.pending_entries} entries coalesced and uploaded.`,
176
+ before: { pending_entries: before.pending_entries, pending_size_bytes: before.pending_size_bytes },
177
+ after: { pending_entries: after.pending_entries, pending_size_bytes: after.pending_size_bytes },
178
+ total_digests_sent: after.sent_digests,
179
+ };
180
+ }