flint-agent 1.14.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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Facts memory — Layer 4 of memory architecture.
|
|
2
|
+
//
|
|
3
|
+
// User/project facts explicitly stated or extracted from conversation.
|
|
4
|
+
// "We use yarn not npm", "project runs on port 8080", "DB is PostgreSQL".
|
|
5
|
+
// Injected into system prompt so agent applies them without being reminded.
|
|
6
|
+
//
|
|
7
|
+
// Internal task reference removed.
|
|
8
|
+
|
|
9
|
+
import { insertFact, getAllFacts, deleteFact, indexEntry, searchFts, setFactPinned } from "./sqlite-store.js";
|
|
10
|
+
|
|
11
|
+
const MAX_FACTS = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Add a fact.
|
|
15
|
+
* @param {string} content — the fact text
|
|
16
|
+
* @param {string} category — general|project|preference|environment
|
|
17
|
+
* @param {string} source — "user" or "agent"
|
|
18
|
+
* @param {number} confidence — 0.0 to 1.0
|
|
19
|
+
* @returns {string} fact id
|
|
20
|
+
*/
|
|
21
|
+
export function addFact(content, category = "general", source = "agent", confidence = 1.0, project = null) {
|
|
22
|
+
if (!content || !content.trim()) return null;
|
|
23
|
+
|
|
24
|
+
// Dedupe — don't add if very similar fact exists (any project)
|
|
25
|
+
const existing = searchFts(content.slice(0, 50), { layer: "facts", limit: 3 });
|
|
26
|
+
for (const e of existing) {
|
|
27
|
+
if (e.text && textSimilarity(e.text, content) > 0.8) {
|
|
28
|
+
return null; // too similar, skip
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Eviction: never evict pinned facts. If cap reached and no unpinned
|
|
33
|
+
// fact exists to evict, silently reject the new fact (prevents the cap
|
|
34
|
+
// from being exceeded when all slots are curated).
|
|
35
|
+
const all = getAllFacts();
|
|
36
|
+
if (all.length >= MAX_FACTS) {
|
|
37
|
+
// getAllFacts returns ORDER BY created_at DESC, so last element is oldest
|
|
38
|
+
const oldestUnpinned = [...all].reverse().find(f => !f.pinned);
|
|
39
|
+
if (!oldestUnpinned) return null; // all slots pinned — refuse new fact
|
|
40
|
+
deleteFact(oldestUnpinned.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const now = new Date();
|
|
44
|
+
// A random tail: two facts added in the same millisecond got the same id,
|
|
45
|
+
// and the second overwrote the first without a word.
|
|
46
|
+
const id = `fact-${now.getTime().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
47
|
+
insertFact({ id, category, content: content.trim(), source, confidence, project, pinned: 0 });
|
|
48
|
+
indexEntry("facts", id, content.trim());
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a fact by id.
|
|
54
|
+
*/
|
|
55
|
+
export function removeFact(id) {
|
|
56
|
+
deleteFact(id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pin a fact — it will never be LRU-evicted. Use for durable knowledge
|
|
61
|
+
* (user preferences, project metadata) that must survive heavy write
|
|
62
|
+
* sessions (benchmarks, bulk agent work).
|
|
63
|
+
*/
|
|
64
|
+
export function pinFact(id) {
|
|
65
|
+
setFactPinned(id, true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Unpin a fact — it becomes eligible for LRU eviction again.
|
|
70
|
+
*/
|
|
71
|
+
export function unpinFact(id) {
|
|
72
|
+
setFactPinned(id, false);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get facts, optionally filtered by category and/or project scope.
|
|
77
|
+
* @param {string|null} category — filter by category, null = all
|
|
78
|
+
* @param {string|null|undefined} project — project scope:
|
|
79
|
+
* undefined/null = all facts (no scope filter)
|
|
80
|
+
* "" (empty string) = global only (project IS NULL)
|
|
81
|
+
* "name" = project "name" + global facts (union)
|
|
82
|
+
*/
|
|
83
|
+
export function getFacts(category = null, project = null) {
|
|
84
|
+
const all = project === null ? getAllFacts() : getAllFacts({ project });
|
|
85
|
+
if (category) return all.filter(f => f.category === category);
|
|
86
|
+
return all;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format facts as system-prompt-ready block. Groups by category.
|
|
91
|
+
* Uses the given project scope: global facts always included; project-scoped
|
|
92
|
+
* only when currentProject matches. If currentProject is null, only globals.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} maxTokens — approximate token budget
|
|
95
|
+
* @param {string|null} currentProject — project name or null
|
|
96
|
+
*/
|
|
97
|
+
export function formatForPrompt(maxTokens = 500, currentProject = null) {
|
|
98
|
+
const all = currentProject
|
|
99
|
+
? getAllFacts({ project: currentProject }) // scoped + global union
|
|
100
|
+
: getAllFacts({ project: "" }); // global only
|
|
101
|
+
if (!all.length) return "";
|
|
102
|
+
|
|
103
|
+
const groups = {};
|
|
104
|
+
for (const f of all) {
|
|
105
|
+
const cat = f.category || "general";
|
|
106
|
+
if (!groups[cat]) groups[cat] = [];
|
|
107
|
+
groups[cat].push(f);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const scopeLabel = currentProject ? `(scope: ${currentProject} + global)` : "(scope: global)";
|
|
111
|
+
const lines = [`Known facts about user and project ${scopeLabel}:`];
|
|
112
|
+
let tokens = 0;
|
|
113
|
+
for (const [cat, facts] of Object.entries(groups)) {
|
|
114
|
+
lines.push(` [${cat}]`);
|
|
115
|
+
for (const f of facts) {
|
|
116
|
+
const marker = f.project ? `§[${f.project}]` : "§";
|
|
117
|
+
const line = ` ${marker} ${f.content}`;
|
|
118
|
+
tokens += line.split(/\s+/).length;
|
|
119
|
+
if (tokens > maxTokens) break;
|
|
120
|
+
lines.push(line);
|
|
121
|
+
}
|
|
122
|
+
if (tokens > maxTokens) break;
|
|
123
|
+
}
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract facts from a conversation turn.
|
|
129
|
+
* Heuristic: look for patterns like "we use X", "our project is Y",
|
|
130
|
+
* "I prefer Z", "the server runs on W".
|
|
131
|
+
* @param {string} userMessage
|
|
132
|
+
* @returns {Array<{content, category, confidence}>}
|
|
133
|
+
*/
|
|
134
|
+
/**
|
|
135
|
+
* Extract facts from a user message. Uses the LLM-based extractor from
|
|
136
|
+
* extract-facts.js (async, semantic). No regex, no hardcoded patterns —
|
|
137
|
+
* the LLM decides what's a durable fact.
|
|
138
|
+
*
|
|
139
|
+
* Gated: only calls LLM for messages >= 20 chars that look like disclosures
|
|
140
|
+
* (contain a pronoun-like signal). Short chit-chat ("ok", "yes", "done")
|
|
141
|
+
* never triggers a call.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} userMessage
|
|
144
|
+
* @returns {Promise<Array<{content, category, confidence}>>}
|
|
145
|
+
*/
|
|
146
|
+
export async function extractFacts(userMessage) {
|
|
147
|
+
if (!userMessage || userMessage.length < 20) return [];
|
|
148
|
+
// Cheap language-neutral pre-filter — skip if message is clearly a question
|
|
149
|
+
// or a bare command. Real disclosures are declarative statements.
|
|
150
|
+
// Previously this used RU+EN keyword regex which was biased against
|
|
151
|
+
// Ukrainian / German / Chinese users. Replaced 2026-04-19.
|
|
152
|
+
const trimmed = userMessage.trim();
|
|
153
|
+
// Skip pure questions (ends with ?)
|
|
154
|
+
if (/\?\s*$/.test(trimmed)) return [];
|
|
155
|
+
// Skip very short declarations (covered by length check above, but also
|
|
156
|
+
// short imperative commands like "read X" or "open Y")
|
|
157
|
+
const wordCount = trimmed.split(/\s+/).length;
|
|
158
|
+
if (wordCount < 5) return [];
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const { extractFacts: extractViaLlm } = await import("./extract-facts.js");
|
|
162
|
+
// extract-facts.js expects messages; wrap the single user message
|
|
163
|
+
const out = await extractViaLlm([{ role: "user", content: userMessage }]);
|
|
164
|
+
if (!Array.isArray(out)) return [];
|
|
165
|
+
return out.map(f => ({ content: f.content, category: f.category || "general", confidence: 0.8 }));
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Simple word-overlap similarity
|
|
172
|
+
function textSimilarity(a, b) {
|
|
173
|
+
const wa = new Set(a.toLowerCase().split(/\s+/));
|
|
174
|
+
const wb = new Set(b.toLowerCase().split(/\s+/));
|
|
175
|
+
let inter = 0;
|
|
176
|
+
for (const w of wa) if (wb.has(w)) inter++;
|
|
177
|
+
const union = wa.size + wb.size - inter;
|
|
178
|
+
return union === 0 ? 0 : inter / union;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const __internal = { MAX_FACTS };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Notification inbox — queues reminders, messages, events
|
|
2
|
+
// Agent sees count in system prompt, reads with check_inbox tool
|
|
3
|
+
// Does NOT interrupt current task focus
|
|
4
|
+
|
|
5
|
+
let _items = []; // [{ id, type, content, from, ts }]
|
|
6
|
+
let _nextId = 1;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Add a notification to the inbox.
|
|
10
|
+
* @param {"reminder"|"message"|"event"} type
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @param {string} [from] - source (e.g. "schedule", "api", "cron")
|
|
13
|
+
*/
|
|
14
|
+
export function pushInbox(type, content, from = "") {
|
|
15
|
+
_items.push({
|
|
16
|
+
id: _nextId++,
|
|
17
|
+
type,
|
|
18
|
+
content,
|
|
19
|
+
from,
|
|
20
|
+
ts: new Date().toISOString(),
|
|
21
|
+
read: false,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get count of unread notifications.
|
|
27
|
+
*/
|
|
28
|
+
export function unreadCount() {
|
|
29
|
+
return _items.filter((i) => !i.read).length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get all unread notifications and mark them as read.
|
|
34
|
+
*/
|
|
35
|
+
export function readInbox() {
|
|
36
|
+
const unread = _items.filter((i) => !i.read);
|
|
37
|
+
for (const item of unread) {
|
|
38
|
+
item.read = true;
|
|
39
|
+
}
|
|
40
|
+
return unread;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get all items (read + unread), last N.
|
|
45
|
+
*/
|
|
46
|
+
export function allInbox(limit = 50) {
|
|
47
|
+
return _items.slice(-limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format inbox summary for system prompt injection.
|
|
52
|
+
* Returns null if no unread notifications.
|
|
53
|
+
*/
|
|
54
|
+
export function inboxPromptHint() {
|
|
55
|
+
const count = unreadCount();
|
|
56
|
+
if (count === 0) return null;
|
|
57
|
+
const types = {};
|
|
58
|
+
for (const item of _items.filter((i) => !i.read)) {
|
|
59
|
+
types[item.type] = (types[item.type] || 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
const parts = Object.entries(types).map(([t, n]) => `${n} ${t}${n > 1 ? "s" : ""}`);
|
|
62
|
+
return `You have ${count} unread notification${count > 1 ? "s" : ""} (${parts.join(", ")}). Use check_inbox tool to read them when you have a moment. Do NOT interrupt your current task.`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import { loadAll, getMemoryStats } from "./store.js";
|
|
5
|
+
|
|
6
|
+
const MEMORY_MD = path.join(config.projectRoot, "MEMORY.md");
|
|
7
|
+
|
|
8
|
+
export function updateMemoryMd() {
|
|
9
|
+
const memories = loadAll();
|
|
10
|
+
const stats = getMemoryStats();
|
|
11
|
+
const grouped = {};
|
|
12
|
+
for (const m of memories) {
|
|
13
|
+
if (!grouped[m.category]) grouped[m.category] = [];
|
|
14
|
+
grouped[m.category].push(m);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let md = `# Agent Memory (${stats.total} entries)\n\n`;
|
|
18
|
+
|
|
19
|
+
for (const [cat, items] of Object.entries(grouped)) {
|
|
20
|
+
md += `## ${cat} (${items.length})\n`;
|
|
21
|
+
for (const m of items) {
|
|
22
|
+
const date = m.created_at?.slice(0, 10) || "unknown";
|
|
23
|
+
const stars = "*".repeat(Math.min(m.importance, 3));
|
|
24
|
+
md += `- [#${m.id}] ${date}: ${m.content} ${stars}\n`;
|
|
25
|
+
}
|
|
26
|
+
md += "\n";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
md += `Last updated: ${new Date().toISOString()}\n`;
|
|
30
|
+
writeFileSync(MEMORY_MD, md, "utf-8");
|
|
31
|
+
return md;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readMemoryMdHead(lines = 50) {
|
|
35
|
+
if (!existsSync(MEMORY_MD)) return "";
|
|
36
|
+
const content = readFileSync(MEMORY_MD, "utf-8");
|
|
37
|
+
return content.split("\n").slice(0, lines).join("\n");
|
|
38
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Patterns memory — Layer 2 of memory architecture.
|
|
2
|
+
//
|
|
3
|
+
// Records per-turn: what user request (tokenized) → which tool the agent
|
|
4
|
+
// chose first. Compiles into a tool-preference table injected at session
|
|
5
|
+
// start. Purpose: triplet-consistency — three paraphrases of the same
|
|
6
|
+
// request should converge on the same tool.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
insertPattern, getAllPatterns, migrateFromJsonl,
|
|
10
|
+
findSimilarPattern, incrementPatternSuccess, replacePatternTool, getTopPatterns,
|
|
11
|
+
} from "./sqlite-store.js";
|
|
12
|
+
|
|
13
|
+
// Run migration on first import (idempotent)
|
|
14
|
+
try { migrateFromJsonl(); } catch {}
|
|
15
|
+
|
|
16
|
+
const STOP_WORDS = new Set([
|
|
17
|
+
// English
|
|
18
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
19
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "should", "could",
|
|
20
|
+
"can", "may", "might", "i", "you", "he", "she", "it", "we", "they",
|
|
21
|
+
"my", "your", "his", "her", "its", "our", "their", "this", "that", "these", "those",
|
|
22
|
+
"to", "of", "in", "on", "at", "for", "with", "by", "from", "as", "and", "or", "but",
|
|
23
|
+
"not", "no", "if", "so", "then", "than", "when", "where", "what", "why", "how",
|
|
24
|
+
"please", "me", "some",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tokenize a request into lowercased, stop-word-filtered tokens.
|
|
29
|
+
* Keeps letters and digits of any script, 3+ chars only.
|
|
30
|
+
*/
|
|
31
|
+
export function tokenize(text) {
|
|
32
|
+
if (!text || typeof text !== "string") return [];
|
|
33
|
+
// Split on anything that is not a letter or digit, in any script
|
|
34
|
+
const raw = text.toLowerCase().split(/[^\p{L}\p{N}_]+/u);
|
|
35
|
+
const out = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const t of raw) {
|
|
38
|
+
if (!t || t.length < 3) continue;
|
|
39
|
+
if (STOP_WORDS.has(t)) continue;
|
|
40
|
+
if (seen.has(t)) continue;
|
|
41
|
+
seen.add(t);
|
|
42
|
+
out.push(t);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record a pattern with versioning support.
|
|
49
|
+
* - If similar tokens + same tool exists: increment success_count (reinforcement)
|
|
50
|
+
* - If similar tokens + different tool: replace tool (correction/versioning)
|
|
51
|
+
* - Otherwise: insert new pattern
|
|
52
|
+
* @param {{ request:string, first_tool:string, all_tools?:string[], ok?:boolean, session_id?:string }} p
|
|
53
|
+
* @returns {string|null} id or null if skipped
|
|
54
|
+
*/
|
|
55
|
+
export function recordPattern(p) {
|
|
56
|
+
if (!p || !p.request || !p.first_tool) return null;
|
|
57
|
+
const tokens = tokenize(p.request);
|
|
58
|
+
if (tokens.length === 0) return null;
|
|
59
|
+
|
|
60
|
+
// Check for existing similar pattern (Jaccard >= 0.5 on tokens)
|
|
61
|
+
const existing = findSimilarPattern(tokens, p.first_tool);
|
|
62
|
+
if (existing) {
|
|
63
|
+
// Same tool: reinforce
|
|
64
|
+
incrementPatternSuccess(existing.id);
|
|
65
|
+
return existing.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if there's a pattern with same tokens but DIFFERENT tool (correction)
|
|
69
|
+
const allPatterns = getAllPatterns();
|
|
70
|
+
for (const ep of allPatterns) {
|
|
71
|
+
if (ep.first_tool === p.first_tool) continue;
|
|
72
|
+
const intersection = tokens.filter(t => (ep.tokens || []).includes(t)).length;
|
|
73
|
+
const union = new Set([...tokens, ...(ep.tokens || [])]).size;
|
|
74
|
+
if (union > 0 && intersection / union >= 0.5) {
|
|
75
|
+
// Correction: replace tool, increment version + fail_count
|
|
76
|
+
replacePatternTool(ep.id, p.first_tool, Array.isArray(p.all_tools) ? p.all_tools.slice(0, 20) : [p.first_tool]);
|
|
77
|
+
return ep.id;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// New pattern
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const record = {
|
|
84
|
+
// Random tail for the same reason as facts.js: one millisecond, one id.
|
|
85
|
+
id: `p-${now.getTime().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
86
|
+
ts: now.toISOString(),
|
|
87
|
+
session_id: p.session_id || "",
|
|
88
|
+
request: String(p.request).slice(0, 500),
|
|
89
|
+
tokens,
|
|
90
|
+
first_tool: p.first_tool,
|
|
91
|
+
all_tools: Array.isArray(p.all_tools) ? p.all_tools.slice(0, 20) : [p.first_tool],
|
|
92
|
+
ok: p.ok !== false,
|
|
93
|
+
success_count: 1,
|
|
94
|
+
fail_count: 0,
|
|
95
|
+
version: 1,
|
|
96
|
+
};
|
|
97
|
+
insertPattern(record);
|
|
98
|
+
return record.id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function loadAll() {
|
|
102
|
+
return getAllPatterns();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* For each tool, find the tokens most predictive of that tool choice.
|
|
107
|
+
* Returns a map: tool_name → [tokens sorted by descriminating power].
|
|
108
|
+
*
|
|
109
|
+
* Method: for each (tool, token) pair, compute conditional probability
|
|
110
|
+
* P(tool | token) = count(tool ∧ token) / count(token)
|
|
111
|
+
* Only tokens seen ≥ MIN_TOKEN_USES times are kept. Only pairs with
|
|
112
|
+
* P ≥ MIN_CONFIDENCE are kept.
|
|
113
|
+
*/
|
|
114
|
+
export function compilePreferences(opts = {}) {
|
|
115
|
+
const MIN_TOKEN_USES = opts.minTokenUses || 3;
|
|
116
|
+
const MIN_CONFIDENCE = opts.minConfidence || 0.6;
|
|
117
|
+
const MAX_TOKENS_PER_TOOL = opts.maxTokensPerTool || 6;
|
|
118
|
+
const records = loadAll();
|
|
119
|
+
// Check total weight (success_count sum), not just record count
|
|
120
|
+
const totalWeight = records.reduce((s, r) => s + (r.success_count ?? 1), 0);
|
|
121
|
+
if (totalWeight < MIN_TOKEN_USES) return {};
|
|
122
|
+
|
|
123
|
+
// Count per token: total + per-tool (weighted by success_count)
|
|
124
|
+
const tokenTotal = new Map();
|
|
125
|
+
const tokenByTool = new Map(); // token → Map(tool → count)
|
|
126
|
+
for (const r of records) {
|
|
127
|
+
if (!r.ok) continue;
|
|
128
|
+
// Filter out patterns with poor success rate
|
|
129
|
+
const sr = (r.success_count ?? 1) / Math.max((r.success_count ?? 1) + (r.fail_count ?? 0), 1);
|
|
130
|
+
if (sr < 0.4) continue; // skip patterns that fail more than 60% of the time
|
|
131
|
+
const weight = r.success_count ?? 1; // use success_count as weight
|
|
132
|
+
for (const tok of r.tokens || []) {
|
|
133
|
+
tokenTotal.set(tok, (tokenTotal.get(tok) || 0) + weight);
|
|
134
|
+
if (!tokenByTool.has(tok)) tokenByTool.set(tok, new Map());
|
|
135
|
+
const tm = tokenByTool.get(tok);
|
|
136
|
+
tm.set(r.first_tool, (tm.get(r.first_tool) || 0) + weight);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// For each tool, collect tokens where P(tool | token) >= threshold
|
|
141
|
+
const prefs = {}; // tool → [{token, confidence, support}]
|
|
142
|
+
for (const [token, total] of tokenTotal) {
|
|
143
|
+
if (total < MIN_TOKEN_USES) continue;
|
|
144
|
+
const tm = tokenByTool.get(token);
|
|
145
|
+
for (const [tool, count] of tm) {
|
|
146
|
+
const confidence = count / total;
|
|
147
|
+
if (confidence < MIN_CONFIDENCE) continue;
|
|
148
|
+
if (!prefs[tool]) prefs[tool] = [];
|
|
149
|
+
prefs[tool].push({ token, confidence, support: count });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort and cap per tool
|
|
154
|
+
for (const tool of Object.keys(prefs)) {
|
|
155
|
+
prefs[tool].sort((a, b) => {
|
|
156
|
+
// Primary: confidence desc; secondary: support desc
|
|
157
|
+
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
|
158
|
+
return b.support - a.support;
|
|
159
|
+
});
|
|
160
|
+
prefs[tool] = prefs[tool].slice(0, MAX_TOKENS_PER_TOOL);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return prefs;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Format compiled preferences as a compact prompt-ready block.
|
|
168
|
+
* @param {Record<string, Array<{token,confidence,support}>>} prefs
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
export function formatForPrompt(prefs) {
|
|
172
|
+
const tools = Object.keys(prefs);
|
|
173
|
+
if (!tools.length) return "";
|
|
174
|
+
const lines = ["Tool preferences learned from past successful sessions:"];
|
|
175
|
+
for (const tool of tools) {
|
|
176
|
+
const items = prefs[tool];
|
|
177
|
+
if (!items?.length) continue;
|
|
178
|
+
const tokens = items.map(i => `${i.token} (${Math.round(i.confidence * 100)}%, n=${i.support})`).join(", ");
|
|
179
|
+
lines.push(` ${tool}: ${tokens}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push("Prefer these tools when the user's request contains the listed keywords.");
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const __internal = { STOP_WORDS };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Project scope detection.
|
|
2
|
+
//
|
|
3
|
+
// Facts are split into global (about the user) and project-scoped (about a
|
|
4
|
+
// specific project). This file resolves "what project are we currently in?"
|
|
5
|
+
// so the rest of the memory layer can filter appropriately.
|
|
6
|
+
//
|
|
7
|
+
// Priority:
|
|
8
|
+
// 1. Explicit override via setCurrentProject(name) — from /project set
|
|
9
|
+
// 2. MEMORY.md in current workspace contains `project: <name>` frontmatter
|
|
10
|
+
// 3. CWD leaf name (last path segment) matches a known project from registry
|
|
11
|
+
// 4. Fallback: null (no project scope; everything treated as global)
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, basename } from "node:path";
|
|
15
|
+
|
|
16
|
+
let _explicitProject = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Explicit override — set by /project set NAME. Takes priority over detection.
|
|
20
|
+
*/
|
|
21
|
+
export function setCurrentProject(name) {
|
|
22
|
+
_explicitProject = name ? String(name).trim().toLowerCase() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clear the explicit override and fall back to detection.
|
|
27
|
+
*/
|
|
28
|
+
export function clearCurrentProject() {
|
|
29
|
+
_explicitProject = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect the current project name.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} [cwd=process.cwd()] — working directory
|
|
36
|
+
* @returns {string|null} lowercase project name, or null if no project
|
|
37
|
+
*/
|
|
38
|
+
export function getCurrentProject(cwd = process.cwd()) {
|
|
39
|
+
if (_explicitProject) return _explicitProject;
|
|
40
|
+
|
|
41
|
+
// 2. MEMORY.md with `project:` frontmatter
|
|
42
|
+
try {
|
|
43
|
+
const memoryPath = join(cwd, "MEMORY.md");
|
|
44
|
+
if (existsSync(memoryPath)) {
|
|
45
|
+
const content = readFileSync(memoryPath, "utf-8").slice(0, 1000);
|
|
46
|
+
const match = content.match(/^project:\s*([a-z0-9-_]+)/im);
|
|
47
|
+
if (match) return match[1].toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
// 3. CWD leaf — only if it matches a plausible project pattern.
|
|
52
|
+
// Using leaf makes this automatic for users working in repo dirs like
|
|
53
|
+
// `flint-agent`, `my-app`, `screenbox`. Normalised: lowercased, strip trailing
|
|
54
|
+
// suffixes like "-agent" that are tooling-specific.
|
|
55
|
+
try {
|
|
56
|
+
const leaf = basename(cwd).toLowerCase();
|
|
57
|
+
// Ignore generic names that could collide: "src", "tmp", "home", "desktop"
|
|
58
|
+
const ignored = new Set(["src", "tmp", "temp", "home", "desktop", "documents", "downloads", "node_modules", ""]);
|
|
59
|
+
if (leaf && !ignored.has(leaf) && /^[a-z0-9][a-z0-9-_]{1,40}$/.test(leaf)) {
|
|
60
|
+
// Strip common suffixes
|
|
61
|
+
return leaf.replace(/-agent$|-app$|-site$/, "");
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reflections memory — auto-extracted learnings at session end.
|
|
2
|
+
//
|
|
3
|
+
// Layer 1 of memory architecture.
|
|
4
|
+
// Backend: SQLite via sqlite-store.js (migrated from JSONL 2026-04-16).
|
|
5
|
+
// At session end, model is prompted with 3 questions (did / wrong / better),
|
|
6
|
+
// response is parsed and stored. At next session start, last N are prepended
|
|
7
|
+
// to system prompt as trusted-context so model doesn't repeat past mistakes.
|
|
8
|
+
|
|
9
|
+
import { insertReflection, getRecentReflections, getAllReflections, migrateFromJsonl } from "./sqlite-store.js";
|
|
10
|
+
|
|
11
|
+
// Run migration on first import (idempotent — skips if already migrated)
|
|
12
|
+
try { migrateFromJsonl(); } catch {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Append a reflection.
|
|
16
|
+
* @param {{ id?:string, ts?:string, session_id?:string, did:string, wrong:string[], better:string[], tags?:string[] }} reflection
|
|
17
|
+
* @returns {string|null} id
|
|
18
|
+
*/
|
|
19
|
+
export function appendReflection(reflection) {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
const record = {
|
|
22
|
+
id: reflection.id || `r-${now.toISOString().slice(0, 10)}-${now.getTime().toString(36).slice(-5)}${Math.random().toString(36).slice(2, 5)}`,
|
|
23
|
+
ts: reflection.ts || now.toISOString(),
|
|
24
|
+
session_id: reflection.session_id || "",
|
|
25
|
+
did: String(reflection.did || "").trim(),
|
|
26
|
+
wrong: Array.isArray(reflection.wrong) ? reflection.wrong.filter(Boolean) : [],
|
|
27
|
+
better: Array.isArray(reflection.better) ? reflection.better.filter(Boolean) : [],
|
|
28
|
+
tags: Array.isArray(reflection.tags) ? reflection.tags.filter(Boolean) : [],
|
|
29
|
+
};
|
|
30
|
+
if (!record.did && record.wrong.length === 0 && record.better.length === 0) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return insertReflection(record);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load last N reflections (most recent first).
|
|
38
|
+
*/
|
|
39
|
+
export function loadRecent(n = 5) {
|
|
40
|
+
return getRecentReflections(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load all reflections. Used by the pattern analyzer (Layer 2).
|
|
45
|
+
*/
|
|
46
|
+
export function loadAll() {
|
|
47
|
+
return getAllReflections();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format reflections as a system-prompt-ready text block.
|
|
52
|
+
* Short, scannable, focused on 'better' guidance.
|
|
53
|
+
* @param {object[]} reflections
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function formatForPrompt(reflections) {
|
|
57
|
+
if (!reflections.length) return "";
|
|
58
|
+
const lines = ["Past lessons from recent sessions (avoid repeating these mistakes):"];
|
|
59
|
+
for (const r of reflections) {
|
|
60
|
+
const date = r.ts ? r.ts.slice(0, 10) : "";
|
|
61
|
+
if (r.wrong.length) {
|
|
62
|
+
lines.push(`\n[${date}] I did wrong:`);
|
|
63
|
+
for (const w of r.wrong.slice(0, 3)) lines.push(` - ${w}`);
|
|
64
|
+
}
|
|
65
|
+
if (r.better.length) {
|
|
66
|
+
lines.push(`[${date}] Next time:`);
|
|
67
|
+
for (const b of r.better.slice(0, 3)) lines.push(` - ${b}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Exposed for tests (legacy references removed — now in sqlite-store)
|
|
74
|
+
export const __internal = {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Retrieval memory — Layer 4 of memory architecture.
|
|
2
|
+
//
|
|
3
|
+
// Unlike Layers 1-3 (frozen at session start in system prompt), Layer 4
|
|
4
|
+
// injects PER-TURN context: on each user message, look up the most
|
|
5
|
+
// similar past requests and surface "similar past requests used tool X"
|
|
6
|
+
// as a just-in-time hint.
|
|
7
|
+
//
|
|
8
|
+
// Backing store: patterns.jsonl (already populated by Layer 2).
|
|
9
|
+
// Similarity: Jaccard token overlap (no embeddings, no extra deps).
|
|
10
|
+
|
|
11
|
+
import { loadAll as loadAllPatterns } from "./patterns.js";
|
|
12
|
+
import { tokenize } from "./patterns.js";
|
|
13
|
+
|
|
14
|
+
function jaccard(aSet, bSet) {
|
|
15
|
+
let inter = 0;
|
|
16
|
+
for (const x of aSet) if (bSet.has(x)) inter++;
|
|
17
|
+
const union = aSet.size + bSet.size - inter;
|
|
18
|
+
return union === 0 ? 0 : inter / union;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Find the top-K most-similar past requests to the given request.
|
|
23
|
+
* @param {string} request
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {number} opts.topK — how many matches to return (default 5)
|
|
26
|
+
* @param {number} opts.minSimilarity — Jaccard threshold (default 0.25)
|
|
27
|
+
* @returns {Array<{record:object, similarity:number}>}
|
|
28
|
+
*/
|
|
29
|
+
export function findSimilarRequests(request, opts = {}) {
|
|
30
|
+
const topK = opts.topK || 5;
|
|
31
|
+
const minSim = opts.minSimilarity ?? 0.25;
|
|
32
|
+
if (!request || typeof request !== "string") return [];
|
|
33
|
+
const queryTokens = new Set(tokenize(request));
|
|
34
|
+
if (queryTokens.size === 0) return [];
|
|
35
|
+
|
|
36
|
+
const records = loadAllPatterns();
|
|
37
|
+
const scored = [];
|
|
38
|
+
for (const r of records) {
|
|
39
|
+
if (!r.tokens || !Array.isArray(r.tokens)) continue;
|
|
40
|
+
const s = jaccard(queryTokens, new Set(r.tokens));
|
|
41
|
+
if (s < minSim) continue;
|
|
42
|
+
scored.push({ record: r, similarity: s });
|
|
43
|
+
}
|
|
44
|
+
scored.sort((a, b) => b.similarity - a.similarity);
|
|
45
|
+
return scored.slice(0, topK);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Aggregate matches into "N past requests similar to this one used tool X
|
|
50
|
+
* in M of N cases". Returns an injectable hint string or empty.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array<{record,similarity}>} matches
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function formatRetrievalHint(matches) {
|
|
56
|
+
if (!matches?.length) return "";
|
|
57
|
+
|
|
58
|
+
// Count first_tool occurrences
|
|
59
|
+
const toolCounts = new Map();
|
|
60
|
+
for (const m of matches) {
|
|
61
|
+
const t = m.record.first_tool;
|
|
62
|
+
if (!t) continue;
|
|
63
|
+
toolCounts.set(t, (toolCounts.get(t) || 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
if (toolCounts.size === 0) return "";
|
|
66
|
+
|
|
67
|
+
const sorted = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
68
|
+
const top = sorted[0];
|
|
69
|
+
const total = matches.length;
|
|
70
|
+
const [topTool, topCount] = top;
|
|
71
|
+
|
|
72
|
+
// Only emit a hint if the dominant tool is majority (>= 50%) and we
|
|
73
|
+
// have at least 2 supporting cases. Otherwise, ambiguous history.
|
|
74
|
+
if (topCount < 2 || topCount / total < 0.5) return "";
|
|
75
|
+
|
|
76
|
+
const topSimilarity = Math.round(matches[0].similarity * 100);
|
|
77
|
+
const parts = [`Similar past requests (top match ${topSimilarity}% token overlap): ${topCount}/${total} used ${topTool}.`];
|
|
78
|
+
if (sorted.length > 1) {
|
|
79
|
+
const others = sorted.slice(1).map(([t, c]) => `${t}(${c})`).join(", ");
|
|
80
|
+
parts.push(`Others: ${others}.`);
|
|
81
|
+
}
|
|
82
|
+
parts.push(`Prefer ${topTool} unless the current request differs materially.`);
|
|
83
|
+
return parts.join(" ");
|
|
84
|
+
}
|