@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.
- package/.context/public/guides/ema-user-guide.md +7 -6
- package/.context/public/guides/mcp-tools-guide.md +46 -23
- package/dist/config/index.js +11 -0
- package/dist/config/workflow-patterns.js +361 -0
- package/dist/mcp/autobuilder.js +2 -2
- package/dist/mcp/domain/generation-schema.js +15 -9
- package/dist/mcp/domain/structural-rules.js +3 -3
- package/dist/mcp/domain/validation-rules.js +20 -27
- package/dist/mcp/domain/workflow-generator.js +3 -3
- package/dist/mcp/domain/workflow-graph.js +1 -1
- package/dist/mcp/guidance.js +60 -1
- package/dist/mcp/handlers/conversation/adapter.js +13 -0
- package/dist/mcp/handlers/conversation/create.js +19 -0
- package/dist/mcp/handlers/conversation/delete.js +18 -0
- package/dist/mcp/handlers/conversation/formatters.js +62 -0
- package/dist/mcp/handlers/conversation/history.js +15 -0
- package/dist/mcp/handlers/conversation/index.js +43 -0
- package/dist/mcp/handlers/conversation/list.js +40 -0
- package/dist/mcp/handlers/conversation/messages.js +13 -0
- package/dist/mcp/handlers/conversation/rename.js +16 -0
- package/dist/mcp/handlers/conversation/send.js +90 -0
- package/dist/mcp/handlers/data/index.js +169 -3
- package/dist/mcp/handlers/feedback/client-id.js +49 -0
- package/dist/mcp/handlers/feedback/coalesce.js +167 -0
- package/dist/mcp/handlers/feedback/index.js +42 -1
- package/dist/mcp/handlers/feedback/outbox.js +301 -0
- package/dist/mcp/handlers/feedback/probes.js +127 -0
- package/dist/mcp/handlers/feedback/remote-store.js +59 -0
- package/dist/mcp/handlers/feedback/store.js +13 -1
- package/dist/mcp/handlers/persona/delete.js +7 -28
- package/dist/mcp/handlers/persona/update.js +7 -26
- package/dist/mcp/handlers/persona/version.js +30 -15
- package/dist/mcp/handlers/template/adapter.js +23 -0
- package/dist/mcp/handlers/template/crud.js +174 -0
- package/dist/mcp/handlers/template/index.js +6 -7
- package/dist/mcp/handlers/workflow/adapter.js +30 -46
- package/dist/mcp/handlers/workflow/index.js +2 -2
- package/dist/mcp/handlers/workflow/validation.js +2 -2
- package/dist/mcp/knowledge-guidance-topics.js +90 -53
- package/dist/mcp/knowledge.js +7 -357
- package/dist/mcp/prompts.js +5 -5
- package/dist/mcp/resources-dynamic.js +46 -38
- package/dist/mcp/resources-validation.js +5 -5
- package/dist/mcp/server.js +38 -5
- package/dist/mcp/tools.js +340 -8
- package/dist/sdk/client-adapter.js +90 -2
- package/dist/sdk/client.js +7 -0
- package/dist/sdk/ema-client.js +242 -27
- package/dist/sdk/generated/agent-catalog.js +96 -39
- package/dist/sdk/generated/deprecated-actions.js +1 -1
- package/dist/sdk/grpc-client.js +67 -5
- package/dist/sync/central-factory.js +86 -0
- package/dist/sync/central-version-storage.js +387 -0
- package/dist/sync/dis-port.js +75 -0
- package/dist/sync/version-policy.js +29 -31
- package/dist/sync/version-storage-interface.js +11 -0
- package/dist/sync/version-storage.js +22 -22
- 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: "
|
|
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,
|
|
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
|
+
}
|