notoken-core 1.5.1 → 2.0.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.
Files changed (99) hide show
  1. package/config/chat-responses.json +767 -0
  2. package/config/concept-clusters.json +31 -0
  3. package/config/entities.json +93 -0
  4. package/config/image-prompts.json +20 -0
  5. package/config/intent-vectors.json +1 -0
  6. package/config/intents.json +5023 -65
  7. package/config/ollama-models.json +193 -0
  8. package/config/rules.json +32 -1
  9. package/dist/automation/discordPatchright.d.ts +35 -0
  10. package/dist/automation/discordPatchright.js +424 -0
  11. package/dist/automation/discordSetup.d.ts +31 -0
  12. package/dist/automation/discordSetup.js +338 -0
  13. package/dist/conversation/coreference.js +44 -4
  14. package/dist/conversation/pendingActions.d.ts +55 -0
  15. package/dist/conversation/pendingActions.js +127 -0
  16. package/dist/conversation/store.d.ts +72 -0
  17. package/dist/conversation/store.js +140 -1
  18. package/dist/conversation/topicTracker.d.ts +36 -0
  19. package/dist/conversation/topicTracker.js +141 -0
  20. package/dist/execution/ssh.d.ts +42 -1
  21. package/dist/execution/ssh.js +532 -3
  22. package/dist/handlers/executor.js +3981 -16
  23. package/dist/index.d.ts +25 -3
  24. package/dist/index.js +36 -2
  25. package/dist/nlp/batchParser.d.ts +30 -0
  26. package/dist/nlp/batchParser.js +77 -0
  27. package/dist/nlp/conceptExpansion.d.ts +54 -0
  28. package/dist/nlp/conceptExpansion.js +136 -0
  29. package/dist/nlp/conceptRouter.d.ts +49 -0
  30. package/dist/nlp/conceptRouter.js +302 -0
  31. package/dist/nlp/confidenceCalibrator.d.ts +62 -0
  32. package/dist/nlp/confidenceCalibrator.js +116 -0
  33. package/dist/nlp/correctionLearner.d.ts +45 -0
  34. package/dist/nlp/correctionLearner.js +207 -0
  35. package/dist/nlp/entitySpellCorrect.d.ts +35 -0
  36. package/dist/nlp/entitySpellCorrect.js +141 -0
  37. package/dist/nlp/knowledgeGraph.d.ts +70 -0
  38. package/dist/nlp/knowledgeGraph.js +380 -0
  39. package/dist/nlp/llmFallback.js +28 -1
  40. package/dist/nlp/multiClassifier.js +91 -6
  41. package/dist/nlp/multiIntent.d.ts +43 -0
  42. package/dist/nlp/multiIntent.js +154 -0
  43. package/dist/nlp/parseIntent.d.ts +6 -1
  44. package/dist/nlp/parseIntent.js +180 -5
  45. package/dist/nlp/ruleParser.js +315 -0
  46. package/dist/nlp/semanticSimilarity.d.ts +30 -0
  47. package/dist/nlp/semanticSimilarity.js +174 -0
  48. package/dist/nlp/vocabularyBuilder.d.ts +43 -0
  49. package/dist/nlp/vocabularyBuilder.js +224 -0
  50. package/dist/nlp/wikidata.d.ts +49 -0
  51. package/dist/nlp/wikidata.js +228 -0
  52. package/dist/policy/confirm.d.ts +10 -0
  53. package/dist/policy/confirm.js +39 -0
  54. package/dist/policy/safety.js +6 -4
  55. package/dist/utils/aliases.d.ts +5 -0
  56. package/dist/utils/aliases.js +39 -0
  57. package/dist/utils/analysis.js +71 -15
  58. package/dist/utils/browser.d.ts +64 -0
  59. package/dist/utils/browser.js +364 -0
  60. package/dist/utils/commandHistory.d.ts +20 -0
  61. package/dist/utils/commandHistory.js +108 -0
  62. package/dist/utils/completer.d.ts +17 -0
  63. package/dist/utils/completer.js +79 -0
  64. package/dist/utils/config.js +32 -2
  65. package/dist/utils/dbQuery.d.ts +25 -0
  66. package/dist/utils/dbQuery.js +248 -0
  67. package/dist/utils/discordDiag.d.ts +35 -0
  68. package/dist/utils/discordDiag.js +826 -0
  69. package/dist/utils/diskCleanup.d.ts +36 -0
  70. package/dist/utils/diskCleanup.js +775 -0
  71. package/dist/utils/entityResolver.d.ts +107 -0
  72. package/dist/utils/entityResolver.js +468 -0
  73. package/dist/utils/imageGen.d.ts +92 -0
  74. package/dist/utils/imageGen.js +2031 -0
  75. package/dist/utils/installTracker.d.ts +57 -0
  76. package/dist/utils/installTracker.js +160 -0
  77. package/dist/utils/multiExec.d.ts +21 -0
  78. package/dist/utils/multiExec.js +141 -0
  79. package/dist/utils/openclawDiag.d.ts +29 -0
  80. package/dist/utils/openclawDiag.js +1035 -0
  81. package/dist/utils/output.js +4 -0
  82. package/dist/utils/platform.js +2 -1
  83. package/dist/utils/progressReporter.d.ts +50 -0
  84. package/dist/utils/progressReporter.js +58 -0
  85. package/dist/utils/projectDetect.d.ts +44 -0
  86. package/dist/utils/projectDetect.js +319 -0
  87. package/dist/utils/projectScanner.d.ts +44 -0
  88. package/dist/utils/projectScanner.js +312 -0
  89. package/dist/utils/shellCompat.d.ts +78 -0
  90. package/dist/utils/shellCompat.js +186 -0
  91. package/dist/utils/smartArchive.d.ts +16 -0
  92. package/dist/utils/smartArchive.js +172 -0
  93. package/dist/utils/smartRetry.d.ts +26 -0
  94. package/dist/utils/smartRetry.js +114 -0
  95. package/dist/utils/updater.d.ts +1 -0
  96. package/dist/utils/updater.js +1 -1
  97. package/dist/utils/version.d.ts +20 -0
  98. package/dist/utils/version.js +212 -0
  99. package/package.json +6 -3
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Knowledge Graph — persistent entity-relationship store.
3
+ * Persists to ~/.notoken/knowledge-graph.json.
4
+ * Auto-populates from entities.json, rules.json, and running system state.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { loadEntities } from "../utils/entityResolver.js";
10
+ // ─── Persistence ────────────────────────────────────────────────────────────
11
+ const GRAPH_DIR = join(homedir(), ".notoken");
12
+ const GRAPH_PATH = join(GRAPH_DIR, "knowledge-graph.json");
13
+ let _graph = null;
14
+ export function loadKnowledgeGraph() {
15
+ if (_graph)
16
+ return _graph;
17
+ if (existsSync(GRAPH_PATH)) {
18
+ try {
19
+ _graph = JSON.parse(readFileSync(GRAPH_PATH, "utf-8"));
20
+ return _graph;
21
+ }
22
+ catch { /* rebuild */ }
23
+ }
24
+ _graph = buildGraph();
25
+ saveKnowledgeGraph(_graph);
26
+ return _graph;
27
+ }
28
+ export function saveKnowledgeGraph(graph) {
29
+ const g = graph ?? _graph;
30
+ if (!g)
31
+ return;
32
+ if (!existsSync(GRAPH_DIR))
33
+ mkdirSync(GRAPH_DIR, { recursive: true });
34
+ writeFileSync(GRAPH_PATH, JSON.stringify(g, null, 2) + "\n");
35
+ _graph = g;
36
+ }
37
+ // ─── Mutation ───────────────────────────────────────────────────────────────
38
+ export function addEntity(name, type, aliases = [], properties = {}) {
39
+ const g = loadKnowledgeGraph();
40
+ const entity = { name, type, aliases, properties };
41
+ g.entities[name] = entity;
42
+ return entity;
43
+ }
44
+ export function addRelation(from, to, relation, properties) {
45
+ const g = loadKnowledgeGraph();
46
+ const rel = { from, to, relation, properties };
47
+ if (!g.relations.some((r) => r.from === from && r.to === to && r.relation === relation))
48
+ g.relations.push(rel);
49
+ return rel;
50
+ }
51
+ // ─── Queries ────────────────────────────────────────────────────────────────
52
+ /** Find an entity by exact name, alias, or prefix (min 3 chars). */
53
+ export function getEntity(name) {
54
+ const g = loadKnowledgeGraph();
55
+ const lower = name.toLowerCase();
56
+ if (g.entities[name])
57
+ return g.entities[name];
58
+ for (const [key, ent] of Object.entries(g.entities)) {
59
+ if (key.toLowerCase() === lower)
60
+ return ent;
61
+ }
62
+ for (const ent of Object.values(g.entities)) {
63
+ if (ent.aliases.some((a) => a.toLowerCase() === lower))
64
+ return ent;
65
+ }
66
+ if (lower.length >= 3) {
67
+ for (const ent of Object.values(g.entities)) {
68
+ if (ent.name.toLowerCase().startsWith(lower))
69
+ return ent;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /** Find all entities related to entityName, optionally filtered by relation type. */
75
+ export function getRelated(entityName, relation) {
76
+ const g = loadKnowledgeGraph();
77
+ const results = [];
78
+ for (const rel of g.relations) {
79
+ if (relation && rel.relation !== relation)
80
+ continue;
81
+ if (rel.from === entityName && g.entities[rel.to])
82
+ results.push({ entity: g.entities[rel.to], relation: rel, direction: "outgoing" });
83
+ else if (rel.to === entityName && g.entities[rel.from])
84
+ results.push({ entity: g.entities[rel.from], relation: rel, direction: "incoming" });
85
+ }
86
+ return results;
87
+ }
88
+ /**
89
+ * Resolve "it", "the server", "that service" using graph context + recent entities.
90
+ * Returns the best candidate. Use resolveCandidates() for all scored options.
91
+ */
92
+ export function resolveReference(text, recentEntities) {
93
+ const candidates = resolveCandidates(text, recentEntities);
94
+ return candidates.length > 0 ? candidates[0].entity : null;
95
+ }
96
+ /**
97
+ * Get all resolution candidates, scored and ranked.
98
+ * Scores: recent entity = 1.0 - (0.1 * position), type match = +0.2, relation match = +0.15
99
+ */
100
+ export function resolveCandidates(text, recentEntities) {
101
+ const lower = text.toLowerCase().trim();
102
+ const g = loadKnowledgeGraph();
103
+ const candidates = [];
104
+ // Direct match — highest confidence
105
+ const direct = getEntity(lower);
106
+ if (direct)
107
+ return [{ entity: direct, score: 1.0, reason: "direct match" }];
108
+ // Determine what type we're looking for
109
+ let wantType = null;
110
+ const typed = lower.match(/^(?:the|that|this)\s+(server|service|database|container|port|package|llm|channel|path|user)$/);
111
+ if (typed)
112
+ wantType = typed[1];
113
+ // For "it"/"that"/"this" — prefer services and containers (actionable things)
114
+ const isAnaphoric = /^(it|that|this)$/.test(lower);
115
+ if (isAnaphoric)
116
+ wantType = null; // consider all types
117
+ // Score recent entities
118
+ for (let i = 0; i < recentEntities.length; i++) {
119
+ const ent = g.entities[recentEntities[i]];
120
+ if (!ent)
121
+ continue;
122
+ let score = 1.0 - (i * 0.15); // Recency: most recent = 1.0, then 0.85, 0.7, ...
123
+ let reason = `recent entity (#${i + 1})`;
124
+ // Type match bonus
125
+ if (wantType && ent.type === wantType) {
126
+ score += 0.2;
127
+ reason += `, type match (${wantType})`;
128
+ }
129
+ // For "it" — prefer services/containers over servers/ports
130
+ if (isAnaphoric) {
131
+ if (ent.type === "service" || ent.type === "container") {
132
+ score += 0.15;
133
+ reason += ", actionable type";
134
+ }
135
+ else if (ent.type === "server") {
136
+ score += 0.05;
137
+ reason += ", server";
138
+ }
139
+ }
140
+ // Relationship bonus — if this entity is related to other recent entities
141
+ for (const other of recentEntities.slice(0, 3)) {
142
+ if (other === recentEntities[i])
143
+ continue;
144
+ const rels = g.relations.filter(r => (r.from === ent.name && r.to === other) || (r.to === ent.name && r.from === other));
145
+ if (rels.length > 0) {
146
+ score += 0.1;
147
+ reason += `, related to ${other}`;
148
+ break;
149
+ }
150
+ }
151
+ candidates.push({ entity: ent, score: Math.min(score, 1.0), reason });
152
+ }
153
+ // If no recent entities matched and we want a type, search all entities of that type
154
+ if (candidates.length === 0 && wantType) {
155
+ for (const ent of Object.values(g.entities)) {
156
+ if (ent.type === wantType) {
157
+ candidates.push({ entity: ent, score: 0.3, reason: `type match (${wantType}), no recency` });
158
+ }
159
+ }
160
+ }
161
+ return candidates.sort((a, b) => b.score - a.score);
162
+ }
163
+ /** Use relationships to infer intent context from tokens. Resolves anaphora and finds target/location. */
164
+ export function inferIntent(tokens, recentEntities = []) {
165
+ const resolvedEntities = [];
166
+ const impliedRelations = [];
167
+ let target, location;
168
+ for (const token of tokens) {
169
+ const resolved = resolveReference(token, recentEntities) ?? getEntity(token);
170
+ if (!resolved)
171
+ continue;
172
+ resolvedEntities.push({ token, entity: resolved });
173
+ if (!target && (resolved.type === "service" || resolved.type === "container" || resolved.type === "package"))
174
+ target = resolved;
175
+ else if (!location && resolved.type === "server")
176
+ location = resolved;
177
+ }
178
+ if (target && location) {
179
+ const g = loadKnowledgeGraph();
180
+ for (const rel of g.relations) {
181
+ if ((rel.from === target.name && rel.to === location.name) || (rel.from === location.name && rel.to === target.name)) {
182
+ impliedRelations.push(rel);
183
+ }
184
+ }
185
+ }
186
+ return { resolvedEntities, impliedRelations, target, location };
187
+ }
188
+ /** General-purpose query — find entities by type and/or property filter. */
189
+ export function queryGraph(filter) {
190
+ const g = loadKnowledgeGraph();
191
+ return Object.values(g.entities).filter((ent) => {
192
+ if (filter.type && ent.type !== filter.type)
193
+ return false;
194
+ if (filter.property !== undefined) {
195
+ const val = ent.properties[filter.property];
196
+ if (val === undefined)
197
+ return false;
198
+ if (filter.value !== undefined && val !== filter.value)
199
+ return false;
200
+ }
201
+ return true;
202
+ });
203
+ }
204
+ // ─── Graph Builder ──────────────────────────────────────────────────────────
205
+ function loadRulesConfig() {
206
+ for (const p of [
207
+ join(import.meta.url.replace("file://", "").replace(/\/[^/]+\/[^/]+$/, ""), "../config/rules.json"),
208
+ join(process.cwd(), "packages/core/config/rules.json"),
209
+ join(process.cwd(), "config/rules.json"),
210
+ ]) {
211
+ if (existsSync(p)) {
212
+ try {
213
+ return JSON.parse(readFileSync(p, "utf-8")).serviceAliases ?? {};
214
+ }
215
+ catch { /* skip */ }
216
+ }
217
+ }
218
+ return {};
219
+ }
220
+ function populateFromEntities(g, ents) {
221
+ for (const [name, srv] of Object.entries(ents.servers)) {
222
+ g.entities[name] = { name, type: "server", aliases: srv.aliases ?? [],
223
+ properties: { host: srv.host, ...(srv.user ? { user: srv.user } : {}), ...(srv.description ? { description: srv.description } : {}) } };
224
+ if (srv.host)
225
+ g.relations.push({ from: name, to: `ip:${srv.host}`, relation: "has_ip" });
226
+ }
227
+ for (const [name, db] of Object.entries(ents.databases)) {
228
+ g.entities[name] = { name, type: "database", aliases: db.aliases ?? [],
229
+ properties: { dbType: db.type, host: db.host, dbName: db.name, ...(db.port ? { port: db.port } : {}), ...(db.user ? { user: db.user } : {}) } };
230
+ if (db.port)
231
+ g.relations.push({ from: name, to: `port:${db.port}`, relation: "has_port" });
232
+ }
233
+ for (const [id, inst] of Object.entries(ents.installations ?? {})) {
234
+ const props = { service: inst.service, environment: inst.environment };
235
+ if (inst.path)
236
+ props.path = inst.path;
237
+ if (inst.version)
238
+ props.version = inst.version;
239
+ if (inst.port)
240
+ props.port = inst.port;
241
+ if (inst.model)
242
+ props.model = inst.model;
243
+ if (inst.status)
244
+ props.status = inst.status;
245
+ g.entities[id] = { name: id, type: "service", aliases: inst.aliases ?? [], properties: props };
246
+ if (inst.port)
247
+ g.relations.push({ from: id, to: `port:${inst.port}`, relation: "has_port" });
248
+ if (inst.model) {
249
+ const llmName = `llm:${inst.model}`;
250
+ if (!g.entities[llmName])
251
+ g.entities[llmName] = { name: llmName, type: "llm", aliases: [inst.model.split("/").pop()], properties: { model: inst.model } };
252
+ g.relations.push({ from: id, to: llmName, relation: "uses" });
253
+ }
254
+ const serverName = Object.keys(ents.servers).find((s) => inst.environment === s || inst.aliases.some((a) => a.includes(s)));
255
+ if (serverName)
256
+ g.relations.push({ from: id, to: serverName, relation: "runs_on" });
257
+ }
258
+ }
259
+ function populateFromRules(g, serviceAliases) {
260
+ for (const [svc, aliases] of Object.entries(serviceAliases)) {
261
+ if (!g.entities[svc]) {
262
+ g.entities[svc] = { name: svc, type: "service", aliases, properties: {} };
263
+ }
264
+ else {
265
+ for (const a of aliases) {
266
+ if (!g.entities[svc].aliases.includes(a))
267
+ g.entities[svc].aliases.push(a);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ function populateFromSystem(g) {
273
+ let execSync;
274
+ try {
275
+ execSync = require("node:child_process").execSync;
276
+ }
277
+ catch {
278
+ return;
279
+ }
280
+ const tryExec = (cmd) => { try {
281
+ return execSync(cmd, { timeout: 5_000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
282
+ }
283
+ catch {
284
+ return "";
285
+ } };
286
+ // Docker containers
287
+ const dockerPs = tryExec("docker ps --format '{{.Names}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null");
288
+ for (const line of (dockerPs || "").split("\n").filter(Boolean)) {
289
+ const [name, image, ports] = line.split("\t");
290
+ if (name)
291
+ g.entities[`container:${name}`] = { name: `container:${name}`, type: "container", aliases: [name], properties: { image: image ?? "", ports: ports ?? "" } };
292
+ }
293
+ // Listening ports
294
+ const ssOut = tryExec("ss -tlnp 2>/dev/null | tail -n +2");
295
+ for (const line of (ssOut || "").split("\n").filter(Boolean)) {
296
+ const portM = line.match(/:(\d+)\s/), procM = line.match(/users:\(\("([^"]+)"/);
297
+ if (!portM)
298
+ continue;
299
+ const key = `port:${portM[1]}`, proc = procM?.[1] ?? "unknown";
300
+ if (!g.entities[key])
301
+ g.entities[key] = { name: key, type: "port", aliases: [`port ${portM[1]}`], properties: { port: Number(portM[1]), process: proc } };
302
+ if (proc !== "unknown" && g.entities[proc])
303
+ g.relations.push({ from: proc, to: key, relation: "has_port" });
304
+ }
305
+ }
306
+ /** Build the full knowledge graph from all sources. */
307
+ export function buildGraph() {
308
+ const g = { entities: {}, relations: [], lastBuilt: new Date().toISOString() };
309
+ populateFromEntities(g, loadEntities(true));
310
+ populateFromRules(g, loadRulesConfig());
311
+ populateFromSystem(g);
312
+ return g;
313
+ }
314
+ /** Force a rebuild of the graph from all sources and persist. */
315
+ export function rebuildGraph() {
316
+ _graph = buildGraph();
317
+ saveKnowledgeGraph(_graph);
318
+ return _graph;
319
+ }
320
+ // ─── Learning from execution ──────────────────────────────────────────────
321
+ /**
322
+ * Learn from a successfully executed intent.
323
+ * Grows the graph over time by recording:
324
+ * - Services that were restarted/checked (type: service)
325
+ * - Servers that were targeted (type: server)
326
+ * - Relationships discovered (service → runs_on → server)
327
+ * - New entities mentioned by the user
328
+ *
329
+ * Called after every successful execution. Persists periodically.
330
+ */
331
+ let _learnCount = 0;
332
+ export function learnFromExecution(intent, fields, rawText) {
333
+ const g = loadKnowledgeGraph();
334
+ const service = fields.service;
335
+ const environment = fields.environment;
336
+ const path = fields.path;
337
+ const target = fields.target;
338
+ // Learn services
339
+ if (service && !g.entities[service]) {
340
+ const type = intent.startsWith("docker.") ? "container" : "service";
341
+ g.entities[service] = { name: service, type, aliases: [], properties: {} };
342
+ }
343
+ // Learn servers/environments
344
+ if (environment && environment !== "local" && environment !== "localhost" && environment !== "dev") {
345
+ if (!g.entities[environment]) {
346
+ g.entities[environment] = { name: environment, type: "server", aliases: [], properties: {} };
347
+ }
348
+ // Learn relationship: service runs_on environment
349
+ if (service) {
350
+ const rel = { from: service, to: environment, relation: "runs_on" };
351
+ if (!g.relations.some(r => r.from === rel.from && r.to === rel.to && r.relation === rel.relation)) {
352
+ g.relations.push(rel);
353
+ }
354
+ }
355
+ }
356
+ // Learn paths
357
+ if (path && path !== "." && !g.entities[`path:${path}`]) {
358
+ g.entities[`path:${path}`] = { name: `path:${path}`, type: "path", aliases: [path], properties: { path } };
359
+ }
360
+ // Learn targets (from disk.scan, file operations, etc.)
361
+ if (target && !g.entities[target]) {
362
+ g.entities[target] = { name: target, type: "service", aliases: [], properties: {} };
363
+ }
364
+ // Learn from specific intents
365
+ if (intent === "entity.define") {
366
+ // User taught us a new entity — already handled by entityResolver
367
+ // but mark the graph as needing a rebuild next time
368
+ g.lastBuilt = undefined;
369
+ }
370
+ // Persist every 5 learn calls (not every single one — too much I/O)
371
+ _learnCount++;
372
+ if (_learnCount % 5 === 0) {
373
+ saveKnowledgeGraph(g);
374
+ }
375
+ }
376
+ /** Flush any pending graph changes to disk. */
377
+ export function flushGraph() {
378
+ if (_graph)
379
+ saveKnowledgeGraph(_graph);
380
+ }
@@ -18,7 +18,7 @@ import { detectLocalPlatform } from "../utils/platform.js";
18
18
  * Order: explicit config → auto-detect Ollama → nothing.
19
19
  */
20
20
  export function isLLMConfigured() {
21
- return !!(process.env.NOTOKEN_LLM_ENDPOINT || process.env.NOTOKEN_LLM_CLI || detectOllama());
21
+ return !!(process.env.NOTOKEN_LLM_ENDPOINT || process.env.NOTOKEN_LLM_CLI || detectOllama() || detectCodex());
22
22
  }
23
23
  /** Which LLM backend is active? */
24
24
  export function getLLMBackend() {
@@ -28,8 +28,25 @@ export function getLLMBackend() {
28
28
  return "api";
29
29
  if (detectOllama())
30
30
  return "ollama";
31
+ if (detectCodex())
32
+ return "codex";
31
33
  return null;
32
34
  }
35
+ let codexChecked = false;
36
+ let codexAvailable = false;
37
+ function detectCodex() {
38
+ if (codexChecked)
39
+ return codexAvailable;
40
+ codexChecked = true;
41
+ try {
42
+ execSync("command -v codex", { timeout: 1000, stdio: "pipe" });
43
+ codexAvailable = true;
44
+ }
45
+ catch {
46
+ codexAvailable = false;
47
+ }
48
+ return codexAvailable;
49
+ }
33
50
  let ollamaChecked = false;
34
51
  let ollamaAvailable = false;
35
52
  function detectOllama() {
@@ -67,6 +84,12 @@ export async function llmFallback(rawText, context) {
67
84
  if (apiResult)
68
85
  return apiResult;
69
86
  }
87
+ // Try Codex (auto-detected local)
88
+ if (detectCodex()) {
89
+ const codexResult = await tryLLMCli(rawText, { ...context, _cli: "codex" });
90
+ if (codexResult)
91
+ return codexResult;
92
+ }
70
93
  // Try Ollama (auto-detected local)
71
94
  if (detectOllama()) {
72
95
  const ollamaResult = await tryOllama(rawText, context);
@@ -91,6 +114,10 @@ async function tryLLMCli(rawText, context) {
91
114
  execSync("command -v chatgpt", { stdio: "pipe" });
92
115
  cmd = `chatgpt ${JSON.stringify(prompt)}`;
93
116
  }
117
+ else if (cli === "codex") {
118
+ execSync("command -v codex", { stdio: "pipe" });
119
+ cmd = `codex ${JSON.stringify(prompt)}`;
120
+ }
94
121
  else {
95
122
  return null;
96
123
  }
@@ -1,9 +1,14 @@
1
1
  import { loadIntents, loadRules } from "../utils/config.js";
2
2
  import { semanticParse, fuzzyMatch } from "./semantic.js";
3
3
  import { parseByRules } from "./ruleParser.js";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { resolve, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { expandQuery } from "./conceptExpansion.js";
4
8
  const CLASSIFIER_WEIGHTS = {
5
9
  synonym: 1.0,
6
10
  semantic: 0.8,
11
+ vector: 0.7,
7
12
  context: 0.6,
8
13
  fuzzy: 0.5,
9
14
  };
@@ -12,8 +17,24 @@ const CLASSIFIER_WEIGHTS = {
12
17
  */
13
18
  export function classifyMulti(rawText, recentIntents) {
14
19
  const votes = [];
15
- // 1. Synonym classifier (existing rule parser)
20
+ // 0. Expand query with synonym clusters for better matching
21
+ // "reboot the server" → "reboot the server restart cycle reload bounce"
22
+ let expandedText = rawText;
23
+ try {
24
+ expandedText = expandQuery(rawText);
25
+ }
26
+ catch { /* concept expansion not available */ }
27
+ // 1. Synonym classifier — run on both original AND expanded text
16
28
  votes.push(...classifySynonym(rawText));
29
+ if (expandedText !== rawText) {
30
+ // Run again on expanded text but with lower weight
31
+ const expandedVotes = classifySynonym(expandedText);
32
+ for (const v of expandedVotes) {
33
+ v.confidence *= 0.7; // Expansion matches are less certain
34
+ v.reason += " (expanded)";
35
+ }
36
+ votes.push(...expandedVotes);
37
+ }
17
38
  // 2. Semantic classifier (compromise-powered)
18
39
  votes.push(...classifySemantic(rawText));
19
40
  // 3. Context classifier (recent history)
@@ -22,19 +43,23 @@ export function classifyMulti(rawText, recentIntents) {
22
43
  }
23
44
  // 4. Fuzzy classifier (keyboard distance)
24
45
  votes.push(...classifyFuzzy(rawText));
25
- // Merge votes into weighted scores
46
+ // 5. Vector classifier (precomputed TF-IDF cosine similarity)
47
+ votes.push(...classifyVector(rawText));
48
+ // Merge votes: max weighted score + bonus for agreement
26
49
  const scoreMap = new Map();
27
50
  for (const vote of votes) {
28
51
  const weight = CLASSIFIER_WEIGHTS[vote.classifier] ?? 1.0;
29
- const existing = scoreMap.get(vote.intent) ?? { total: 0, count: 0 };
30
- existing.total += vote.confidence * weight;
52
+ const weighted = vote.confidence * weight;
53
+ const existing = scoreMap.get(vote.intent) ?? { maxWeighted: 0, totalWeighted: 0, count: 0 };
54
+ existing.maxWeighted = Math.max(existing.maxWeighted, weighted);
55
+ existing.totalWeighted += weighted;
31
56
  existing.count += 1;
32
57
  scoreMap.set(vote.intent, existing);
33
58
  }
34
59
  const scores = Array.from(scoreMap.entries())
35
- .map(([intent, { total, count }]) => ({
60
+ .map(([intent, { maxWeighted, count }]) => ({
36
61
  intent,
37
- score: total / count,
62
+ score: maxWeighted + Math.min(0.15, (count - 1) * 0.05),
38
63
  votes: count,
39
64
  }))
40
65
  .sort((a, b) => b.score - a.score);
@@ -179,3 +204,63 @@ function scoreEntityMatch(parse, def) {
179
204
  }
180
205
  return matches / total;
181
206
  }
207
+ let _vectorData = null;
208
+ function loadVectors() {
209
+ if (_vectorData)
210
+ return _vectorData;
211
+ const paths = [
212
+ resolve(dirname(fileURLToPath(import.meta.url)), "../../config/intent-vectors.json"),
213
+ resolve(process.cwd(), "config/intent-vectors.json"),
214
+ ];
215
+ for (const p of paths) {
216
+ if (existsSync(p)) {
217
+ try {
218
+ _vectorData = JSON.parse(readFileSync(p, "utf-8"));
219
+ return _vectorData;
220
+ }
221
+ catch { /* skip */ }
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+ const VECTOR_STOP = new Set(["a", "an", "the", "is", "it", "in", "on", "to", "for", "of", "and", "or", "my", "me", "i", "we", "you", "do", "does", "did", "be", "am", "are", "was", "were", "have", "has", "had", "this", "that", "what", "which", "who", "how", "where", "when", "why", "not", "no", "but", "if", "so", "at", "by", "with", "from", "up", "out", "can", "could", "would", "should", "will", "may", "might", "just", "about", "all", "please"]);
227
+ function classifyVector(rawText) {
228
+ const data = loadVectors();
229
+ if (!data)
230
+ return [];
231
+ const tokens = rawText.toLowerCase().replace(/[^a-z0-9_.\-\/]/g, " ").split(/\s+/).filter((w) => w.length > 1 && !VECTOR_STOP.has(w));
232
+ if (tokens.length === 0)
233
+ return [];
234
+ const vocabIndex = new Map(data.vocab.map((v, i) => [v, i]));
235
+ const inputVec = {};
236
+ let magnitude = 0;
237
+ const tf = new Map();
238
+ for (const t of tokens)
239
+ tf.set(t, (tf.get(t) ?? 0) + 1);
240
+ for (const [term, count] of tf) {
241
+ const idx = vocabIndex.get(term);
242
+ if (idx !== undefined) {
243
+ inputVec[idx] = count;
244
+ magnitude += count * count;
245
+ }
246
+ }
247
+ magnitude = Math.sqrt(magnitude);
248
+ if (magnitude === 0)
249
+ return [];
250
+ for (const idx of Object.keys(inputVec))
251
+ inputVec[Number(idx)] /= magnitude;
252
+ const votes = [];
253
+ for (const [intentName, intentVec] of Object.entries(data.vectors)) {
254
+ let dot = 0;
255
+ for (const [idx, val] of Object.entries(inputVec)) {
256
+ const iv = intentVec[idx];
257
+ if (iv)
258
+ dot += val * iv;
259
+ }
260
+ if (dot > 0.1) {
261
+ votes.push({ classifier: "vector", intent: intentName, confidence: Math.min(0.95, dot), reason: `TF-IDF cosine: ${dot.toFixed(3)}` });
262
+ }
263
+ }
264
+ votes.sort((a, b) => b.confidence - a.confidence);
265
+ return votes.slice(0, 3);
266
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Multi-intent parser.
3
+ *
4
+ * Splits compound sentences into individual intents and creates a plan.
5
+ *
6
+ * "check if the firewall is blocking port 443 and also check dns for my domain"
7
+ * → Step 1: firewall.list (check port 443)
8
+ * Step 2: dns.lookup (check domain)
9
+ *
10
+ * "show me disk usage, check memory, and list running containers"
11
+ * → Step 1: server.check_disk
12
+ * Step 2: server.check_memory
13
+ * Step 3: docker.list
14
+ *
15
+ * Splitting rules:
16
+ * - Split on: "and", "also", "then", "after that", ",", ";"
17
+ * - But NOT inside quoted strings or after "and" that joins nouns ("cats and dogs")
18
+ * - Each part is parsed independently through rule parser + concept router
19
+ * - Only creates a plan if 2+ distinct intents are found
20
+ */
21
+ export interface PlanStep {
22
+ intent: string;
23
+ rawText: string;
24
+ confidence: number;
25
+ description: string;
26
+ requiresConfirmation: boolean;
27
+ riskLevel: string;
28
+ }
29
+ export interface MultiIntentPlan {
30
+ steps: PlanStep[];
31
+ originalText: string;
32
+ isSingleIntent: boolean;
33
+ }
34
+ /**
35
+ * Split a compound sentence into parts.
36
+ */
37
+ export declare function splitCompoundSentence(text: string): string[];
38
+ /**
39
+ * Parse a potentially compound sentence into a multi-step plan.
40
+ * Returns a single-step plan if only one intent is found.
41
+ */
42
+ export declare function parseMultiIntent(rawText: string): MultiIntentPlan;
43
+ export declare function formatPlanSteps(plan: MultiIntentPlan): string;