@diegoaltoworks/chatter 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/flows/__fixtures__/broken/handler.d.ts +3 -0
- package/dist/flows/__fixtures__/broken/handler.d.ts.map +1 -0
- package/dist/flows/__fixtures__/greeting/handler.d.ts +3 -0
- package/dist/flows/__fixtures__/greeting/handler.d.ts.map +1 -0
- package/dist/flows/__fixtures__/greeting/prefill.d.ts +3 -0
- package/dist/flows/__fixtures__/greeting/prefill.d.ts.map +1 -0
- package/dist/flows/engine.d.ts +54 -0
- package/dist/flows/engine.d.ts.map +1 -0
- package/dist/flows/engine.test.d.ts +9 -0
- package/dist/flows/engine.test.d.ts.map +1 -0
- package/dist/flows/index.d.ts +25 -0
- package/dist/flows/index.d.ts.map +1 -0
- package/dist/flows/index.js +481 -0
- package/dist/flows/index.mjs +448 -0
- package/dist/flows/intent.d.ts +10 -0
- package/dist/flows/intent.d.ts.map +1 -0
- package/dist/flows/intent.test.d.ts +6 -0
- package/dist/flows/intent.test.d.ts.map +1 -0
- package/dist/flows/loader.d.ts +11 -0
- package/dist/flows/loader.d.ts.map +1 -0
- package/dist/flows/loader.test.d.ts +8 -0
- package/dist/flows/loader.test.d.ts.map +1 -0
- package/dist/flows/params.d.ts +15 -0
- package/dist/flows/params.d.ts.map +1 -0
- package/dist/flows/params.test.d.ts +2 -0
- package/dist/flows/params.test.d.ts.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.test.d.ts +2 -0
- package/dist/flows/registry.test.d.ts.map +1 -0
- package/dist/flows/session.d.ts +15 -0
- package/dist/flows/session.d.ts.map +1 -0
- package/dist/flows/session.test.d.ts +7 -0
- package/dist/flows/session.test.d.ts.map +1 -0
- package/dist/flows/types.d.ts +101 -0
- package/dist/flows/types.d.ts.map +1 -0
- package/dist/flows/utils.d.ts +4 -0
- package/dist/flows/utils.d.ts.map +1 -0
- package/dist/flows/utils.test.d.ts +2 -0
- package/dist/flows/utils.test.d.ts.map +1 -0
- package/dist/personas/greeter.d.ts +19 -0
- package/dist/personas/greeter.d.ts.map +1 -0
- package/dist/personas/greeter.test.d.ts +2 -0
- package/dist/personas/greeter.test.d.ts.map +1 -0
- package/dist/personas/index.d.ts +14 -0
- package/dist/personas/index.d.ts.map +1 -0
- package/dist/personas/index.js +206 -0
- package/dist/personas/index.mjs +177 -0
- package/dist/personas/resolver.d.ts +67 -0
- package/dist/personas/resolver.d.ts.map +1 -0
- package/dist/personas/resolver.test.d.ts +2 -0
- package/dist/personas/resolver.test.d.ts.map +1 -0
- package/dist/personas/timeContext.d.ts +7 -0
- package/dist/personas/timeContext.d.ts.map +1 -0
- package/dist/personas/timeContext.test.d.ts +2 -0
- package/dist/personas/timeContext.test.d.ts.map +1 -0
- package/package.json +14 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../../../src/flows/__fixtures__/broken/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,eAAO,MAAM,OAAO,EAAE,WAErB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../../../src/flows/__fixtures__/greeting/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,eAAO,MAAM,OAAO,EAAE,WAMrB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prefill.d.ts","sourceRoot":"","sources":["../../../../src/flows/__fixtures__/greeting/prefill.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,eAAO,MAAM,kBAAkB,EAAE,WAEhC,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow engine — orchestrates the flow lifecycle: triggering, multi-turn
|
|
3
|
+
* parameter collection, execution and cancellation. Channel-agnostic: it
|
|
4
|
+
* takes a caller-chosen `sessionKey` and returns structured results, never
|
|
5
|
+
* channel-specific presentation (see {@link FlowHandlerResult}).
|
|
6
|
+
*/
|
|
7
|
+
import type OpenAI from "openai";
|
|
8
|
+
import type { FlowResult, FlowSessionStore, LoadedFlow } from "./types";
|
|
9
|
+
export interface FlowEngineOptions {
|
|
10
|
+
/** OpenAI client used for intent detection and parameter extraction. */
|
|
11
|
+
client: OpenAI;
|
|
12
|
+
/** Model for both LLM calls. */
|
|
13
|
+
model: string;
|
|
14
|
+
/** Directory containing flow subdirectories (flow.json/handler.ts/instructions.md). */
|
|
15
|
+
flowsDir: string;
|
|
16
|
+
/** Session persistence — see `./session` for the shipped Turso-backed store. */
|
|
17
|
+
sessionStore: FlowSessionStore;
|
|
18
|
+
/**
|
|
19
|
+
* Keyword -> flow id map checked before LLM intent detection. Unset
|
|
20
|
+
* disables the keyword-trigger step entirely (LLM detection only).
|
|
21
|
+
*/
|
|
22
|
+
criticalKeywords?: Record<string, string>;
|
|
23
|
+
/** Confidence threshold an LLM detection must clear to trigger a flow. Default 0.7. */
|
|
24
|
+
minConfidence?: number;
|
|
25
|
+
/** Phrases that cancel an active flow. Default: cancel/nevermind/stop/"forget it"/quit. */
|
|
26
|
+
cancelKeywords?: string[];
|
|
27
|
+
/** Message returned when a flow is cancelled. */
|
|
28
|
+
cancelMessage?: string;
|
|
29
|
+
/** Message returned when flow processing errors out. */
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
/**
|
|
32
|
+
* How long an inactive session may sit before it's treated as abandoned
|
|
33
|
+
* and dropped rather than resumed. Unset: sessions never expire on their
|
|
34
|
+
* own (only completion or a cancel keyword clears one).
|
|
35
|
+
*/
|
|
36
|
+
sessionTtlMs?: number;
|
|
37
|
+
/** Clock, injectable for deterministic tests. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
}
|
|
40
|
+
export interface FlowEngine {
|
|
41
|
+
/** Loads (or reloads) every flow directory under `flowsDir`. */
|
|
42
|
+
loadFlows(): Promise<void>;
|
|
43
|
+
getFlow(id: string): LoadedFlow | undefined;
|
|
44
|
+
getAllFlows(): LoadedFlow[];
|
|
45
|
+
/** Advances (or starts) the flow for `sessionKey` given the caller's latest message. */
|
|
46
|
+
process(sessionKey: string, message: string, options?: {
|
|
47
|
+
conversationContext?: string[];
|
|
48
|
+
channel?: string;
|
|
49
|
+
/** Passed through to a freshly-triggered flow's `prefill`, when it has one. */
|
|
50
|
+
prefillContext?: Record<string, unknown>;
|
|
51
|
+
}): Promise<FlowResult>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createFlowEngine(options: FlowEngineOptions): FlowEngine;
|
|
54
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/flows/engine.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAKjC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAGxE,MAAM,WAAW,iBAAiB;IAChC,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,YAAY,EAAE,gBAAgB,CAAC;IAC/B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,uFAAuF;IACvF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2FAA2F;IAC3F,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,iDAAiD;IACjD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC5C,WAAW,IAAI,UAAU,EAAE,CAAC;IAC5B,wFAAwF;IACxF,OAAO,CACL,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QACR,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;QAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,+EAA+E;QAC/E,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAC1C,GACA,OAAO,CAAC,UAAU,CAAC,CAAC;CACxB;AAMD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAkKvE"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow engine tests — the full processFlow-equivalent lifecycle: triggering,
|
|
3
|
+
* multi-turn param collection, completion, cancellation and error handling.
|
|
4
|
+
* Uses a faked OpenAI client (never a real API call) and an in-memory
|
|
5
|
+
* FlowSessionStore standing in for the Turso-backed one, which has its own
|
|
6
|
+
* dedicated tests in session.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=engine.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.test.d.ts","sourceRoot":"","sources":["../../src/flows/engine.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow engine — directory-loaded, schema-driven slot-filling flows with
|
|
3
|
+
* hybrid (keyword + LLM) intent matching and multi-instance-safe session
|
|
4
|
+
* state. Published as the `@diegoaltoworks/chatter/flows` subpath so the
|
|
5
|
+
* core install stays untouched by it.
|
|
6
|
+
*
|
|
7
|
+
* Presentation stays out: `process()` returns a structured {@link FlowResult}
|
|
8
|
+
* ({@link FlowHandlerResult.message} plus an optional `result` payload) —
|
|
9
|
+
* rendering it for voice, SMS, WhatsApp or anything else is the calling
|
|
10
|
+
* plugin's job. This module is the designated future home for graph-based
|
|
11
|
+
* orchestration.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export type { FlowEngine, FlowEngineOptions } from "./engine";
|
|
16
|
+
export { createFlowEngine } from "./engine";
|
|
17
|
+
export { detectIntent } from "./intent";
|
|
18
|
+
export { loadFlowsFromDirectory } from "./loader";
|
|
19
|
+
export { extractParameters } from "./params";
|
|
20
|
+
export type { FlowRegistry } from "./registry";
|
|
21
|
+
export { createFlowRegistry } from "./registry";
|
|
22
|
+
export { createTursoFlowSessionStore } from "./session";
|
|
23
|
+
export type { FlowDefinition, FlowExtractionResult, FlowHandler, FlowHandlerContext, FlowHandlerResult, FlowPrefill, FlowResult, FlowSchema, FlowSchemaProperty, FlowSessionState, FlowSessionStore, IntentDetection, LoadedFlow, } from "./types";
|
|
24
|
+
export { shouldExitFlow } from "./utils";
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/flows/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAC7C,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,2BAA2B,EAAE,MAAM,WAAW,CAAC;AACxD,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,WAAW,EACX,kBAAkB,EAClB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,UAAU,GACX,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/flows/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createFlowEngine: () => createFlowEngine,
|
|
24
|
+
createFlowRegistry: () => createFlowRegistry,
|
|
25
|
+
createTursoFlowSessionStore: () => createTursoFlowSessionStore,
|
|
26
|
+
detectIntent: () => detectIntent,
|
|
27
|
+
extractParameters: () => extractParameters,
|
|
28
|
+
loadFlowsFromDirectory: () => loadFlowsFromDirectory,
|
|
29
|
+
shouldExitFlow: () => shouldExitFlow
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/flows/intent.ts
|
|
34
|
+
async function detectIntent(client, model, message, flows, conversationContext) {
|
|
35
|
+
const contextStr = conversationContext && conversationContext.length > 0 ? `
|
|
36
|
+
|
|
37
|
+
Recent conversation:
|
|
38
|
+
${conversationContext.join("\n")}` : "";
|
|
39
|
+
const flowDescriptions = Array.from(flows.entries()).map(([id, flow], index) => {
|
|
40
|
+
const keywords = flow.definition.triggerKeywords.join(", ");
|
|
41
|
+
return `${index + 1}. ${id} -- ${flow.definition.description}
|
|
42
|
+
- Keywords: ${keywords}`;
|
|
43
|
+
}).join("\n\n");
|
|
44
|
+
const systemPrompt = `You are an intent detector.
|
|
45
|
+
|
|
46
|
+
Detect ONE category. Use these EXACT ids (they must match flow ids):
|
|
47
|
+
|
|
48
|
+
${flowDescriptions}
|
|
49
|
+
|
|
50
|
+
${flows.size + 1}. chatbot -- All other questions (general fallback)
|
|
51
|
+
${contextStr}
|
|
52
|
+
|
|
53
|
+
Return JSON with:
|
|
54
|
+
- "intent": one of the ids above (exact string)
|
|
55
|
+
- "confidence": 0.0-1.0 (how certain you are)
|
|
56
|
+
- "reasoning": brief explanation
|
|
57
|
+
|
|
58
|
+
Rules:
|
|
59
|
+
- If uncertain, always return "chatbot" with low confidence`;
|
|
60
|
+
try {
|
|
61
|
+
const response = await client.chat.completions.create({
|
|
62
|
+
model,
|
|
63
|
+
temperature: 0.1,
|
|
64
|
+
response_format: { type: "json_object" },
|
|
65
|
+
messages: [
|
|
66
|
+
{ role: "system", content: systemPrompt },
|
|
67
|
+
{ role: "user", content: message }
|
|
68
|
+
]
|
|
69
|
+
});
|
|
70
|
+
const content = response.choices[0]?.message?.content || "{}";
|
|
71
|
+
const raw = JSON.parse(content);
|
|
72
|
+
const validFlowIds = new Set(flows.keys());
|
|
73
|
+
const intent = validFlowIds.has(raw.intent) ? raw.intent : "chatbot";
|
|
74
|
+
const confidence = typeof raw.confidence === "number" ? raw.confidence : 0;
|
|
75
|
+
return { intent, confidence, reasoning: raw.reasoning };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.warn(
|
|
78
|
+
`[flows] intent detection failed: ${error instanceof Error ? error.message : String(error)}`
|
|
79
|
+
);
|
|
80
|
+
return { intent: "chatbot", confidence: 0, reasoning: "exception, defaulting to chatbot" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/flows/loader.ts
|
|
85
|
+
var import_node_fs = require("node:fs");
|
|
86
|
+
var import_node_path = require("node:path");
|
|
87
|
+
var SKIPPED_DIR_NAMES = /* @__PURE__ */ new Set(["lib", "tests", "registry"]);
|
|
88
|
+
async function loadFlowsFromDirectory(flowsDir) {
|
|
89
|
+
const flows = /* @__PURE__ */ new Map();
|
|
90
|
+
if (!(0, import_node_fs.existsSync)(flowsDir)) {
|
|
91
|
+
console.warn(`[flows] directory does not exist: ${flowsDir}`);
|
|
92
|
+
return flows;
|
|
93
|
+
}
|
|
94
|
+
const flowDirs = (0, import_node_fs.readdirSync)(flowsDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).filter((dirent) => !SKIPPED_DIR_NAMES.has(dirent.name)).map((dirent) => dirent.name);
|
|
95
|
+
for (const flowName of flowDirs) {
|
|
96
|
+
try {
|
|
97
|
+
flows.set(flowName, await loadFlow(flowsDir, flowName));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.warn(
|
|
100
|
+
`[flows] failed to load "${flowName}": ${error instanceof Error ? error.message : String(error)}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return flows;
|
|
105
|
+
}
|
|
106
|
+
async function loadFlow(flowsDir, flowName) {
|
|
107
|
+
const flowPath = (0, import_node_path.resolve)(flowsDir, flowName);
|
|
108
|
+
const definitionPath = (0, import_node_path.join)(flowPath, "flow.json");
|
|
109
|
+
const instructionsPath = (0, import_node_path.join)(flowPath, "instructions.md");
|
|
110
|
+
const handlerPath = (0, import_node_path.join)(flowPath, "handler.ts");
|
|
111
|
+
if (!(0, import_node_fs.existsSync)(definitionPath)) {
|
|
112
|
+
throw new Error(`flow.json not found for ${flowName}`);
|
|
113
|
+
}
|
|
114
|
+
if (!(0, import_node_fs.existsSync)(instructionsPath)) {
|
|
115
|
+
throw new Error(`instructions.md not found for ${flowName}`);
|
|
116
|
+
}
|
|
117
|
+
if (!(0, import_node_fs.existsSync)(handlerPath)) {
|
|
118
|
+
throw new Error(`handler.ts not found for ${flowName}`);
|
|
119
|
+
}
|
|
120
|
+
const definition = JSON.parse((0, import_node_fs.readFileSync)(definitionPath, "utf-8"));
|
|
121
|
+
if (definition.id !== flowName) {
|
|
122
|
+
throw new Error(`Flow id mismatch: ${definition.id} !== ${flowName}`);
|
|
123
|
+
}
|
|
124
|
+
if (!definition.triggerKeywords || definition.triggerKeywords.length === 0) {
|
|
125
|
+
throw new Error(`Flow ${flowName} has no trigger keywords`);
|
|
126
|
+
}
|
|
127
|
+
if (!definition.schema || Object.keys(definition.schema.properties ?? {}).length === 0) {
|
|
128
|
+
throw new Error(`Flow ${flowName} has no schema`);
|
|
129
|
+
}
|
|
130
|
+
const handlerModule = await import(handlerPath);
|
|
131
|
+
const handler = handlerModule.execute;
|
|
132
|
+
if (typeof handler !== "function") {
|
|
133
|
+
throw new Error(`Flow ${flowName} handler.ts must export an 'execute' function`);
|
|
134
|
+
}
|
|
135
|
+
const prefillPath = (0, import_node_path.join)(flowPath, "prefill.ts");
|
|
136
|
+
let prefill;
|
|
137
|
+
if ((0, import_node_fs.existsSync)(prefillPath)) {
|
|
138
|
+
const prefillModule = await import(prefillPath);
|
|
139
|
+
if (typeof prefillModule.prefillFromContext === "function") {
|
|
140
|
+
prefill = prefillModule.prefillFromContext;
|
|
141
|
+
} else {
|
|
142
|
+
console.warn(
|
|
143
|
+
`[flows] "${flowName}" prefill.ts does not export a prefillFromContext function`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { definition, handler, instructionsPath, prefill };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/flows/params.ts
|
|
151
|
+
var import_node_fs2 = require("node:fs");
|
|
152
|
+
function isMissing(value) {
|
|
153
|
+
return value === void 0 || value === null;
|
|
154
|
+
}
|
|
155
|
+
async function extractParameters(client, model, flow, message, existingParams, now = Date.now) {
|
|
156
|
+
const instructions = (0, import_node_fs2.readFileSync)(flow.instructionsPath, "utf-8");
|
|
157
|
+
const schema = flow.definition.schema;
|
|
158
|
+
const properties = schema.properties || {};
|
|
159
|
+
const required = schema.required || [];
|
|
160
|
+
const schemaDescription = Object.entries(properties).map(
|
|
161
|
+
([key, field]) => `- ${key} (${field.type}${required.includes(key) ? ", required" : ""}): ${field.description || ""}`
|
|
162
|
+
).join("\n");
|
|
163
|
+
const existingParamsStr = Object.keys(existingParams).length > 0 ? `
|
|
164
|
+
|
|
165
|
+
Already collected:
|
|
166
|
+
${JSON.stringify(existingParams, null, 2)}` : "";
|
|
167
|
+
const missingFields = required.filter((key) => isMissing(existingParams[key]));
|
|
168
|
+
const currentlyAsking = missingFields.length > 0 ? missingFields[0] : null;
|
|
169
|
+
const nowDate = new Date(now());
|
|
170
|
+
const todayStr = nowDate.toISOString().split("T")[0];
|
|
171
|
+
const currentYear = nowDate.getFullYear();
|
|
172
|
+
const currentMonth = nowDate.getMonth() + 1;
|
|
173
|
+
const nextYear = currentYear + 1;
|
|
174
|
+
const tomorrow = new Date(nowDate);
|
|
175
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
176
|
+
const tomorrowStr = tomorrow.toISOString().split("T")[0];
|
|
177
|
+
const dateContext = `
|
|
178
|
+
|
|
179
|
+
## CRITICAL DATE CONTEXT
|
|
180
|
+
TODAY IS: ${todayStr} (year ${currentYear}, month ${currentMonth})
|
|
181
|
+
|
|
182
|
+
**DATE EXTRACTION RULES:**
|
|
183
|
+
- "tomorrow" = ${tomorrowStr}
|
|
184
|
+
- Months 1-${currentMonth} = year ${nextYear}
|
|
185
|
+
- Months ${currentMonth + 1}-12 = year ${currentYear}
|
|
186
|
+
- NEVER output a date before ${todayStr}`;
|
|
187
|
+
const currentFieldHint = currentlyAsking ? `
|
|
188
|
+
|
|
189
|
+
## IMPORTANT CONTEXT
|
|
190
|
+
We just asked the user for: "${currentlyAsking}"
|
|
191
|
+
If the user's response is a simple value, interpret it as the value for the "${currentlyAsking}" parameter.` : "";
|
|
192
|
+
const systemPrompt = `${instructions}
|
|
193
|
+
${dateContext}
|
|
194
|
+
|
|
195
|
+
## Schema
|
|
196
|
+
${schemaDescription}
|
|
197
|
+
${existingParamsStr}
|
|
198
|
+
${currentFieldHint}
|
|
199
|
+
|
|
200
|
+
## Task
|
|
201
|
+
Extract any parameters from the user's message that match the schema above.
|
|
202
|
+
Return JSON with:
|
|
203
|
+
- "extractedParams": object with any NEW parameters found
|
|
204
|
+
|
|
205
|
+
Do not compute "allParamsFilled" - the caller recomputes it from the merged params.`;
|
|
206
|
+
const response = await client.chat.completions.create({
|
|
207
|
+
model,
|
|
208
|
+
temperature: 0.2,
|
|
209
|
+
response_format: { type: "json_object" },
|
|
210
|
+
messages: [
|
|
211
|
+
{ role: "system", content: systemPrompt },
|
|
212
|
+
{ role: "user", content: message }
|
|
213
|
+
]
|
|
214
|
+
});
|
|
215
|
+
const content = response.choices[0]?.message?.content || "{}";
|
|
216
|
+
const result = JSON.parse(content);
|
|
217
|
+
const extractedParams = Object.fromEntries(
|
|
218
|
+
Object.entries(result.extractedParams || {}).filter(([, value]) => !isMissing(value))
|
|
219
|
+
);
|
|
220
|
+
const mergedParams = { ...existingParams, ...extractedParams };
|
|
221
|
+
const allParamsFilled = required.every((key) => !isMissing(mergedParams[key]));
|
|
222
|
+
let nextPrompt;
|
|
223
|
+
if (!allParamsFilled) {
|
|
224
|
+
const missingKey = required.find((key) => isMissing(mergedParams[key]));
|
|
225
|
+
if (missingKey) {
|
|
226
|
+
nextPrompt = `What is the ${missingKey.replace(/([A-Z])/g, " $1").toLowerCase()}?`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { extractedParams, allParamsFilled, nextPrompt };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/flows/registry.ts
|
|
233
|
+
var import_node_fs3 = require("node:fs");
|
|
234
|
+
function createFlowRegistry(flows, criticalKeywords = {}) {
|
|
235
|
+
const keywordEntries = Object.entries(criticalKeywords);
|
|
236
|
+
return {
|
|
237
|
+
getFlow(id) {
|
|
238
|
+
return flows.get(id);
|
|
239
|
+
},
|
|
240
|
+
getAllFlows() {
|
|
241
|
+
return Array.from(flows.values());
|
|
242
|
+
},
|
|
243
|
+
getInstructions(id) {
|
|
244
|
+
const flow = flows.get(id);
|
|
245
|
+
if (!flow) {
|
|
246
|
+
throw new Error(`Flow ${id} not found`);
|
|
247
|
+
}
|
|
248
|
+
return (0, import_node_fs3.readFileSync)(flow.instructionsPath, "utf-8");
|
|
249
|
+
},
|
|
250
|
+
matchByKeyword(message) {
|
|
251
|
+
const lowerMessage = message.toLowerCase();
|
|
252
|
+
for (const [keyword, flowId] of keywordEntries) {
|
|
253
|
+
if (lowerMessage.includes(keyword.toLowerCase())) {
|
|
254
|
+
const flow = flows.get(flowId);
|
|
255
|
+
if (flow) return flow;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return void 0;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/flows/utils.ts
|
|
264
|
+
var DEFAULT_CANCEL_KEYWORDS = ["cancel", "nevermind", "never mind", "stop", "forget it", "quit"];
|
|
265
|
+
function escapeRegExp(value) {
|
|
266
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
267
|
+
}
|
|
268
|
+
function containsWholePhrase(message, phrase) {
|
|
269
|
+
return new RegExp(`(^|\\W)${escapeRegExp(phrase)}($|\\W)`, "i").test(message);
|
|
270
|
+
}
|
|
271
|
+
function shouldExitFlow(message, cancelKeywords = DEFAULT_CANCEL_KEYWORDS) {
|
|
272
|
+
return cancelKeywords.some((keyword) => containsWholePhrase(message, keyword));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/flows/engine.ts
|
|
276
|
+
var DEFAULT_MIN_CONFIDENCE = 0.7;
|
|
277
|
+
var DEFAULT_CANCEL_MESSAGE = "Okay, cancelled.";
|
|
278
|
+
var DEFAULT_ERROR_MESSAGE = "Sorry, something went wrong. Please try again.";
|
|
279
|
+
function createFlowEngine(options) {
|
|
280
|
+
const {
|
|
281
|
+
client,
|
|
282
|
+
model,
|
|
283
|
+
flowsDir,
|
|
284
|
+
sessionStore,
|
|
285
|
+
criticalKeywords,
|
|
286
|
+
minConfidence = DEFAULT_MIN_CONFIDENCE,
|
|
287
|
+
cancelKeywords,
|
|
288
|
+
cancelMessage = DEFAULT_CANCEL_MESSAGE,
|
|
289
|
+
errorMessage = DEFAULT_ERROR_MESSAGE,
|
|
290
|
+
sessionTtlMs,
|
|
291
|
+
now = Date.now
|
|
292
|
+
} = options;
|
|
293
|
+
let registry = createFlowRegistry(/* @__PURE__ */ new Map());
|
|
294
|
+
async function matchFlow(message, conversationContext) {
|
|
295
|
+
const byKeyword = registry.matchByKeyword(message);
|
|
296
|
+
if (byKeyword) return byKeyword;
|
|
297
|
+
const allFlows = registry.getAllFlows();
|
|
298
|
+
if (allFlows.length === 0) return void 0;
|
|
299
|
+
const flowMap = new Map(allFlows.map((flow) => [flow.definition.id, flow]));
|
|
300
|
+
const detection = await detectIntent(client, model, message, flowMap, conversationContext);
|
|
301
|
+
if (detection.confidence >= minConfidence) {
|
|
302
|
+
return registry.getFlow(detection.intent);
|
|
303
|
+
}
|
|
304
|
+
return void 0;
|
|
305
|
+
}
|
|
306
|
+
async function runToCompletionOrPrompt(flow, sessionKey, message, existingParams, attempts, startedAt, channel) {
|
|
307
|
+
const extraction = await extractParameters(client, model, flow, message, existingParams, now);
|
|
308
|
+
const mergedParams = { ...existingParams, ...extraction.extractedParams };
|
|
309
|
+
if (extraction.allParamsFilled) {
|
|
310
|
+
const result = await flow.handler(mergedParams, { sessionKey, channel });
|
|
311
|
+
await sessionStore.clear(sessionKey);
|
|
312
|
+
return {
|
|
313
|
+
isFlowActive: false,
|
|
314
|
+
message: result.message,
|
|
315
|
+
flowCompleted: true,
|
|
316
|
+
flowSuccess: result.success,
|
|
317
|
+
result: result.result
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
await sessionStore.set(sessionKey, {
|
|
321
|
+
flowId: flow.definition.id,
|
|
322
|
+
params: mergedParams,
|
|
323
|
+
attempts: attempts + 1,
|
|
324
|
+
startedAt
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
isFlowActive: true,
|
|
328
|
+
message: extraction.nextPrompt || "Could you provide more details?",
|
|
329
|
+
flowCompleted: false
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
async loadFlows() {
|
|
334
|
+
const flows = await loadFlowsFromDirectory(flowsDir);
|
|
335
|
+
registry = createFlowRegistry(flows, criticalKeywords);
|
|
336
|
+
},
|
|
337
|
+
getFlow(id) {
|
|
338
|
+
return registry.getFlow(id);
|
|
339
|
+
},
|
|
340
|
+
getAllFlows() {
|
|
341
|
+
return registry.getAllFlows();
|
|
342
|
+
},
|
|
343
|
+
async process(sessionKey, message, processOptions) {
|
|
344
|
+
const channel = processOptions?.channel;
|
|
345
|
+
let activeSession = await sessionStore.get(sessionKey);
|
|
346
|
+
if (activeSession && sessionTtlMs !== void 0 && now() - activeSession.startedAt > sessionTtlMs) {
|
|
347
|
+
await sessionStore.clear(sessionKey);
|
|
348
|
+
activeSession = null;
|
|
349
|
+
}
|
|
350
|
+
if (activeSession && shouldExitFlow(message, cancelKeywords)) {
|
|
351
|
+
await sessionStore.clear(sessionKey);
|
|
352
|
+
return {
|
|
353
|
+
isFlowActive: false,
|
|
354
|
+
message: cancelMessage,
|
|
355
|
+
flowCompleted: false,
|
|
356
|
+
cancelled: true
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (activeSession) {
|
|
360
|
+
const flow = registry.getFlow(activeSession.flowId);
|
|
361
|
+
if (!flow) {
|
|
362
|
+
await sessionStore.clear(sessionKey);
|
|
363
|
+
return { isFlowActive: false, message: errorMessage, flowCompleted: false, error: true };
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return await runToCompletionOrPrompt(
|
|
367
|
+
flow,
|
|
368
|
+
sessionKey,
|
|
369
|
+
message,
|
|
370
|
+
activeSession.params,
|
|
371
|
+
activeSession.attempts,
|
|
372
|
+
activeSession.startedAt,
|
|
373
|
+
channel
|
|
374
|
+
);
|
|
375
|
+
} catch {
|
|
376
|
+
return { isFlowActive: true, message: errorMessage, flowCompleted: false, error: true };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const matched = await matchFlow(message, processOptions?.conversationContext);
|
|
380
|
+
if (!matched) {
|
|
381
|
+
return { isFlowActive: false, message: "", flowCompleted: false };
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const globalParams = matched.prefill ? matched.prefill(sessionKey, processOptions?.prefillContext ?? {}) : {};
|
|
385
|
+
return await runToCompletionOrPrompt(
|
|
386
|
+
matched,
|
|
387
|
+
sessionKey,
|
|
388
|
+
message,
|
|
389
|
+
globalParams,
|
|
390
|
+
0,
|
|
391
|
+
now(),
|
|
392
|
+
channel
|
|
393
|
+
);
|
|
394
|
+
} catch {
|
|
395
|
+
return { isFlowActive: false, message: errorMessage, flowCompleted: false, error: true };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/flows/session.ts
|
|
402
|
+
var schemaReady = /* @__PURE__ */ new WeakMap();
|
|
403
|
+
function ensureSessionSchema(client, tableName) {
|
|
404
|
+
let perTable = schemaReady.get(client);
|
|
405
|
+
if (!perTable) {
|
|
406
|
+
perTable = /* @__PURE__ */ new Map();
|
|
407
|
+
schemaReady.set(client, perTable);
|
|
408
|
+
}
|
|
409
|
+
const tables = perTable;
|
|
410
|
+
let ready = tables.get(tableName);
|
|
411
|
+
if (!ready) {
|
|
412
|
+
ready = client.execute(
|
|
413
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
414
|
+
session_key TEXT PRIMARY KEY,
|
|
415
|
+
flow_id TEXT NOT NULL,
|
|
416
|
+
params TEXT NOT NULL,
|
|
417
|
+
attempts INTEGER NOT NULL,
|
|
418
|
+
started_at INTEGER NOT NULL
|
|
419
|
+
)`
|
|
420
|
+
).catch((error) => {
|
|
421
|
+
tables.delete(tableName);
|
|
422
|
+
throw error;
|
|
423
|
+
});
|
|
424
|
+
tables.set(tableName, ready);
|
|
425
|
+
}
|
|
426
|
+
return ready;
|
|
427
|
+
}
|
|
428
|
+
var VALID_TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
429
|
+
function createTursoFlowSessionStore(client, tableName = "flow_sessions") {
|
|
430
|
+
if (!VALID_TABLE_NAME.test(tableName)) {
|
|
431
|
+
throw new Error(`Invalid flow session store table name: ${tableName}`);
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
async get(sessionKey) {
|
|
435
|
+
await ensureSessionSchema(client, tableName);
|
|
436
|
+
const result = await client.execute({
|
|
437
|
+
sql: `SELECT flow_id, params, attempts, started_at FROM ${tableName} WHERE session_key = ?`,
|
|
438
|
+
args: [sessionKey]
|
|
439
|
+
});
|
|
440
|
+
const row = result.rows[0];
|
|
441
|
+
if (!row) return null;
|
|
442
|
+
return {
|
|
443
|
+
flowId: String(row.flow_id),
|
|
444
|
+
params: JSON.parse(String(row.params)),
|
|
445
|
+
attempts: Number(row.attempts),
|
|
446
|
+
startedAt: Number(row.started_at)
|
|
447
|
+
};
|
|
448
|
+
},
|
|
449
|
+
async set(sessionKey, state) {
|
|
450
|
+
await ensureSessionSchema(client, tableName);
|
|
451
|
+
await client.execute({
|
|
452
|
+
sql: `INSERT INTO ${tableName} (session_key, flow_id, params, attempts, started_at) VALUES (?, ?, ?, ?, ?)
|
|
453
|
+
ON CONFLICT(session_key) DO UPDATE SET flow_id = excluded.flow_id, params = excluded.params, attempts = excluded.attempts, started_at = excluded.started_at`,
|
|
454
|
+
args: [
|
|
455
|
+
sessionKey,
|
|
456
|
+
state.flowId,
|
|
457
|
+
JSON.stringify(state.params),
|
|
458
|
+
state.attempts,
|
|
459
|
+
state.startedAt
|
|
460
|
+
]
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
async clear(sessionKey) {
|
|
464
|
+
await ensureSessionSchema(client, tableName);
|
|
465
|
+
await client.execute({
|
|
466
|
+
sql: `DELETE FROM ${tableName} WHERE session_key = ?`,
|
|
467
|
+
args: [sessionKey]
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
473
|
+
0 && (module.exports = {
|
|
474
|
+
createFlowEngine,
|
|
475
|
+
createFlowRegistry,
|
|
476
|
+
createTursoFlowSessionStore,
|
|
477
|
+
detectIntent,
|
|
478
|
+
extractParameters,
|
|
479
|
+
loadFlowsFromDirectory,
|
|
480
|
+
shouldExitFlow
|
|
481
|
+
});
|