v4.3.0: Chroma 벡터 메모리 시스템 구축 + 하드코딩 게이트 정리

- USER.md/SOUL.md 문자캡 잘림 보완용 벡터 검색(Chroma, EmbeddingGemma) 신설
- 일별 대화로그 LLM 추출 파이프라인(user_facts/daily_extracts 컬렉션 분리)
- 검증-강제 게이트(isFactualInfoRequest 등)를 prompt-gates.ts로 분리, 개인 인프라
  별명(지서버/클로서버) 관련 불필요한 web_search 강제 호출 버그 수정
- Qwen3-Embedding-8B는 실측 결과 이미지생성 GPU와 자원 충돌 확인되어 폐기,
  EmbeddingGemma(경량+다국어)로 최종 선택

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-24 16:06:29 +09:00
co-authored by Claude Sonnet 5
parent df856877e8
commit 7bc1b1bb65
10 changed files with 827 additions and 120 deletions
+3
View File
@@ -121,6 +121,9 @@ mnt/
.tmp_openclaw_*/
.tmp_codex_*
# --- VECTOR MEMORY (Chroma venv + data, large binary) ---
.chroma/
# --- NODE ---
node_modules/
dist/
@@ -22,7 +22,7 @@
- Local LLM Server name: 지서버 (Z-Server). [2026-07-03] [2026-07-03]
- Web server name: 클로서버 (Claw-Server). [2026-07-03] [2026-07-03]
- Web server (클로서버) 사양: HP ProDesk 600 G1 DM, 16GB RAM. [2026-07-06] [2026-07-06]
- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09]
- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB ×2개(총 24GB VRAM). [2026-07-09] [2026-07-09]
- 클로서버(Claw-Server) AI 미디어 파이프라인 구성:
[음성 STT/TTS]
+99 -2
View File
@@ -1,12 +1,12 @@
{
"name": "smallclaw",
"version": "4.2.1",
"version": "4.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smallclaw",
"version": "4.2.1",
"version": "4.3.0",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
@@ -14,6 +14,7 @@
"@xterm/xterm": "^5.5.0",
"avr8js": "^0.21.0",
"better-sqlite3": "^12.9.0",
"chromadb": "^3.5.0",
"commander": "^14.0.3",
"cors": "^2.8.6",
"croner": "^10.0.1",
@@ -879,6 +880,102 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/chromadb": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/chromadb/-/chromadb-3.5.0.tgz",
"integrity": "sha512-A68f2RQjY3R95Pzy0j3ykOu6fi+WWCN1tjbvzrZXZoYQ1d7ZjgWr1FyzitZCeaz/0qmc8y2LEK7QmlxloOAs9Q==",
"dependencies": {
"semver": "^7.7.1"
},
"bin": {
"chroma": "dist/cli.mjs"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"chromadb-js-bindings-darwin-arm64": "^1.3.4",
"chromadb-js-bindings-darwin-x64": "^1.3.4",
"chromadb-js-bindings-linux-arm64-gnu": "^1.3.4",
"chromadb-js-bindings-linux-x64-gnu": "^1.3.4",
"chromadb-js-bindings-win32-x64-msvc": "^1.3.4"
}
},
"node_modules/chromadb-js-bindings-darwin-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/chromadb-js-bindings-darwin-arm64/-/chromadb-js-bindings-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-pDdWCcR0hCz3pSDiIijBito7YG6FumewfzYE2mIjSwjrO1CKSfQKLwDdTVK4ZNpVGQa16ePoMX+pg7ShSUARJg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/chromadb-js-bindings-darwin-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/chromadb-js-bindings-darwin-x64/-/chromadb-js-bindings-darwin-x64-1.3.4.tgz",
"integrity": "sha512-5vKVFXSFo+S9qC9by/7oofjcXqQK4IGHiUnPrvTfc7B52gNlPSKeyDO1wha556COc3KtXSZjICEH0nYBJ8UXng==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/chromadb-js-bindings-linux-arm64-gnu": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/chromadb-js-bindings-linux-arm64-gnu/-/chromadb-js-bindings-linux-arm64-gnu-1.3.4.tgz",
"integrity": "sha512-ELiqDZzU5mZd1BvZugGrhoMkVeNyQaa+PGizd06WIpIJVoHY+S+9ZBjdeM6ZkRnL3vTxD79XBrosxNWhzqy/kg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/chromadb-js-bindings-linux-x64-gnu": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/chromadb-js-bindings-linux-x64-gnu/-/chromadb-js-bindings-linux-x64-gnu-1.3.4.tgz",
"integrity": "sha512-HmTUe7PEIaBngCQ70Yyle81z2wYyg9OMgJYyRUNBYCBp4ED6zTmdaOro41Vs/c/h2UyQeBXFIKNmHDeD2Nr2fw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/chromadb-js-bindings-win32-x64-msvc": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/chromadb-js-bindings-win32-x64-msvc/-/chromadb-js-bindings-win32-x64-msvc-1.3.4.tgz",
"integrity": "sha512-punrofQRzKspNMxHmv24qoPeRycV2T3KfedekGV4lOuPPG14i6qggS4x/OWLSAK7ozPfBbrzCejvCa5wfvujhQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "4.2.2",
"version": "4.3.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
@@ -42,6 +42,7 @@
"@xterm/xterm": "^5.5.0",
"avr8js": "^0.21.0",
"better-sqlite3": "^12.9.0",
"chromadb": "^3.5.0",
"commander": "^14.0.3",
"cors": "^2.8.6",
"croner": "^10.0.1",
+119
View File
@@ -0,0 +1,119 @@
// Backfill: LLM-extract atomic, reusable facts from raw daily conversation logs and store them
// in the daily_extracts Chroma collection (separate from memory_write's clean user_facts
// collection — see project_vector_memory_chroma memory for why). Naive paragraph-chunking of
// raw transcripts was tried first and abandoned (too noisy, see migrate-memory-to-chroma.ts) —
// this replaces that approach with real extraction.
import fs from 'fs';
import path from 'path';
import { addVector, hasAnyVector, DAILY_EXTRACTS_COLLECTION } from '../src/gateway/memory-vector';
const WORKSPACES: { workspace: string; label: string }[] = [
{ workspace: '/srv/homeclaw/.smallclaw/users/papa/workspace', label: 'papa' },
{ workspace: '/srv/homeclaw/.smallclaw/users/jasmine/workspace', label: 'jasmine' },
{ workspace: '/srv/homeclaw/.smallclaw/users/cherry/workspace', label: 'cherry' },
{ workspace: '/srv/homeclaw/.smallclaw/workspace', label: 'global' },
];
const OLLAMA_ENDPOINT = 'http://localhost:11434';
const EXTRACT_MODEL = 'gemma4:31b-cloud'; // matches homeclaw's configured primary model
async function extractFacts(dayContent: string): Promise<string[]> {
const prompt = `다음은 하루치 대화 로그입니다. 이 중에서 나중에 다시 참고할 가치가 있는 사실만 뽑아줘.
규칙:
- 재사용 가능한 구체적 정보만 (하드웨어 스펙, 설정값, 결정사항, 프로젝트 진행상황, 사용자의 취향/제약사항 등)
- 일회성 질문-답변, 잡담, 이미 끝난 계산/설명은 제외
- 각 사실은 한 줄에 하나씩, 문맥 없이도 이해되는 완결된 문장으로 작성 (예: "클로서버는 RTX 3060 12GB 2개를 사용한다" 처럼 — 어느 대상 얘기인지 반드시 명시)
- 뽑을 게 없으면 "없음"이라고만 답해
- 사실 목록 외의 설명이나 서두는 쓰지 마
--- 로그 시작 ---
${dayContent}
--- 로그 끝 ---`;
const res = await fetch(`${OLLAMA_ENDPOINT}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: EXTRACT_MODEL,
messages: [{ role: 'user', content: prompt }],
stream: false,
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`extract HTTP ${res.status}`);
const data: any = await res.json();
const raw = String(data?.message?.content || '').trim();
if (!raw || raw === '없음') return [];
return raw
.split('\n')
.map(l => l.trim().replace(/^[-*]\s*/, ''))
.filter(l => l && l !== '없음');
}
async function processWorkspace(workspace: string, label: string) {
const memDir = path.join(workspace, 'memory');
if (!fs.existsSync(memDir)) {
console.log(`[${label}] no memory dir, skip`);
return;
}
// Never process today's file — it's still being written to during the day, so extracting
// it early would capture a partial day and then skip it forever (hasAnyVector marks it
// "done"). A nightly cron run naturally picks up "yesterday" once it's actually complete.
const today = new Date().toISOString().slice(0, 10);
const dailyFiles = fs.readdirSync(memDir)
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
.filter(f => f.replace('.md', '') < today)
.sort();
let totalFacts = 0, daysProcessed = 0, daysSkipped = 0, daysFailed = 0;
for (const f of dailyFiles) {
const date = f.replace('.md', '');
const content = fs.readFileSync(path.join(memDir, f), 'utf-8').trim();
if (!content) { daysSkipped++; continue; }
if (await hasAnyVector(DAILY_EXTRACTS_COLLECTION, { workspace, date })) {
daysSkipped++;
continue;
}
try {
const facts = await extractFacts(content);
if (facts.length === 0) {
// Record a sentinel so hasAnyVector() sees this date as processed on future reruns —
// otherwise a legitimately fact-free day gets re-sent to the LLM every single rerun.
await addVector(DAILY_EXTRACTS_COLLECTION, {
id: `${workspace}:daily-extract:${date}:none`,
text: '(해당 날짜에 재사용 가치 있는 사실 없음)',
metadata: { workspace, date, source: 'daily-extract', empty: true },
});
} else {
for (let i = 0; i < facts.length; i++) {
const id = `${workspace}:daily-extract:${date}:${i}`;
await addVector(DAILY_EXTRACTS_COLLECTION, {
id,
text: facts[i],
metadata: { workspace, date, source: 'daily-extract' },
});
}
}
totalFacts += facts.length;
daysProcessed++;
console.log(`[${label}] ${date}: ${facts.length}개 추출`);
} catch (err: any) {
daysFailed++;
console.warn(`[${label}] ${date} 실패: ${err.message}`);
}
}
console.log(`[${label}] 완료 — ${daysProcessed}일 처리, ${totalFacts}개 사실, ${daysSkipped}일 스킵(빈파일), ${daysFailed}일 실패`);
}
async function main() {
for (const { workspace, label } of WORKSPACES) {
await processWorkspace(workspace, label);
}
}
main().then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });
+147
View File
@@ -0,0 +1,147 @@
// One-off backfill: index existing USER.md/SOUL.md bullet facts and daily memory files into
// Chroma. Run once after wiring up memory-vector.ts. Safe to re-run (uses deterministic IDs,
// Chroma's add() upserts on matching ID... actually add() errors on duplicate ID in this
// version, so this script skips ids already present).
import fs from 'fs';
import path from 'path';
import { addMemoryVector } from '../src/gateway/memory-vector';
const WORKSPACES: { workspace: string; label: string }[] = [
{ workspace: '/srv/homeclaw/.smallclaw/users/papa/workspace', label: 'papa' },
{ workspace: '/srv/homeclaw/.smallclaw/users/jasmine/workspace', label: 'jasmine' },
{ workspace: '/srv/homeclaw/.smallclaw/users/cherry/workspace', label: 'cherry' },
{ workspace: '/srv/homeclaw/.smallclaw/workspace', label: 'global' },
];
// nomic-embed-text's context window is 2048 tokens — long daily-memory files (some 30KB+)
// blow past that and the embed call 500s. Chunk on paragraph boundaries, ~3000 chars/chunk
// (conservative for mixed Korean/English token density) rather than embedding the whole file.
function chunkText(text: string, maxChars = 1500): string[] {
const paragraphs = text.split(/\n\n+/);
const chunks: string[] = [];
let current = '';
for (const para of paragraphs) {
if (current && (current.length + para.length + 2) > maxChars) {
chunks.push(current);
current = para;
} else {
current = current ? `${current}\n\n${para}` : para;
}
// A single paragraph longer than maxChars on its own — hard-split it.
while (current.length > maxChars) {
chunks.push(current.slice(0, maxChars));
current = current.slice(maxChars);
}
}
if (current) chunks.push(current);
return chunks;
}
// memory_write always appends a trailing [YYYY-MM-DD] to what it writes (see the memory_write
// handler in server-v2.ts) — so a bullet WITHOUT one was never written atomically via that path;
// it's hand-authored nested content (a "parent:" line introducing a block, often with [bracket]
// sub-headers underneath, e.g. "클로서버 AI 미디어 파이프라인 구성:" > "[음성 STT/TTS]" > "- TTS 메인: ...").
// Splitting those sub-bullets into standalone vectors loses which parent they belong to — verified
// 2026-07-24: a recalled "TTS 메인: OmniVoice..." fact (really about 클로서버) got misattributed to
// 지서버 by the model once it lost that context. Fix: group undated bullets with their nearest
// colon-ending parent (+ bracket sub-header, if any) into one combined fact instead of splitting.
function parseBullets(fileContent: string, fileLabel: 'user' | 'soul'): { category: string; text: string; date: string }[] {
const facts: { category: string; text: string; date: string }[] = [];
let currentCategory = 'general';
let parentLine: string | null = null;
let bracketHeader: string | null = null;
let buffer: string[] = [];
const flush = () => {
if (buffer.length === 0) return;
const prefixParts = [parentLine, bracketHeader].filter(Boolean);
const text = (prefixParts.length ? `${prefixParts.join(' — ')}: ` : '') + buffer.join(' / ');
facts.push({ category: currentCategory, text, date: '' });
buffer = [];
};
for (const line of fileContent.split('\n')) {
const headerMatch = line.match(/^##\s+(.+)$/);
if (headerMatch) {
flush();
parentLine = null;
bracketHeader = null;
currentCategory = headerMatch[1].trim();
continue;
}
const bracketMatch = line.match(/^\[(.+)\]$/);
if (bracketMatch) {
flush();
bracketHeader = bracketMatch[1].trim();
continue;
}
const bulletMatch = line.match(/^-\s+(.+)$/);
if (!bulletMatch) continue;
const text = bulletMatch[1].trim();
const dateMatch = text.match(/\[(\d{4}-\d{2}-\d{2})\]/);
if (dateMatch) {
// Standalone dated fact — flush any pending nested block first, then emit this as-is.
flush();
parentLine = null;
bracketHeader = null;
facts.push({ category: currentCategory, text, date: dateMatch[1] });
} else if (/:$/.test(text)) {
// Introduces a new nested block.
flush();
parentLine = text.replace(/:$/, '');
bracketHeader = null;
} else if (parentLine) {
// Sub-item of the current nested block.
buffer.push(text);
} else {
// Undated, no parent context (e.g. a plain continuation bullet) — keep as its own fact
// rather than silently dropping it.
facts.push({ category: currentCategory, text, date: '' });
}
}
flush();
return facts;
}
async function migrateWorkspace(workspace: string, label: string) {
let added = 0, skipped = 0, failed = 0;
for (const file of ['USER.md', 'SOUL.md'] as const) {
const p = path.join(workspace, 'prompts', file);
if (!fs.existsSync(p)) continue;
const content = fs.readFileSync(p, 'utf-8');
const facts = parseBullets(content, file === 'USER.md' ? 'user' : 'soul');
for (let i = 0; i < facts.length; i++) {
const f = facts[i];
const id = `${workspace}:${file}:${f.category}:migrated:${i}`;
try {
await addMemoryVector({
id,
text: f.text,
metadata: { workspace, file: file === 'USER.md' ? 'user' : 'soul', category: f.category, date: f.date || 'unknown', source: 'migration' },
});
added++;
} catch (err: any) {
if (String(err.message || '').includes('already exists') || String(err.message || '').includes('Insert of existing embedding ID')) skipped++;
else { failed++; console.warn(` [${label}] failed ${id}: ${err.message}`); }
}
}
}
// Daily memory files (raw conversation transcripts) are deliberately NOT migrated.
// 2026-07-24 test: naive paragraph-chunked transcripts embed noisily and drowned out the
// clean USER.md/SOUL.md bullet facts in top-K results (e.g. a "지서버 hardware spec" query
// surfaced unrelated weather/incident-report chunks instead of the actual hardware fact).
// Chunking raw dialogue well enough to be useful (extraction/summarization, not naive
// paragraph splitting) is a separate, harder problem — out of scope for this pass.
console.log(`[${label}] added=${added} skipped(existing)=${skipped} failed=${failed}`);
}
async function main() {
for (const { workspace, label } of WORKSPACES) {
await migrateWorkspace(workspace, label);
}
}
main().then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });
+49
View File
@@ -0,0 +1,49 @@
// One-off: re-embed existing Chroma collections with a new embedding model. Reuses already-
// stored document text + metadata + ids — does NOT re-run migrate-memory-to-chroma.ts's USER.md
// parsing or extract-daily-memory.ts's LLM extraction, since only the embedding vectors need to
// change, not the underlying facts. Run whenever EMBED_MODEL changes in memory-vector.ts.
import { ChromaClient } from 'chromadb';
import { addVector, USER_FACTS_COLLECTION, DAILY_EXTRACTS_COLLECTION } from '../src/gateway/memory-vector';
const CHROMA_HOST = 'localhost';
const CHROMA_PORT = 8100;
async function reembedCollection(name: string) {
const client = new ChromaClient({ host: CHROMA_HOST, port: CHROMA_PORT });
let existing: { ids: string[]; documents: (string | null)[]; metadatas: any[] } | null = null;
try {
const col = await client.getCollection({ name });
const res = await col.get({ limit: 10000 });
existing = { ids: res.ids || [], documents: res.documents || [], metadatas: res.metadatas || [] };
} catch (err: any) {
console.log(`[${name}] no existing collection or empty (${err.message}) — nothing to reembed`);
return;
}
console.log(`[${name}] ${existing.ids.length}개 기존 항목 발견, 재임베딩 시작`);
await client.deleteCollection({ name });
let done = 0, failed = 0;
for (let i = 0; i < existing.ids.length; i++) {
const id = existing.ids[i];
const text = existing.documents[i];
const metadata = existing.metadatas[i] || undefined;
if (!text) continue;
try {
await addVector(name, { id, text, metadata });
done++;
if (done % 50 === 0) console.log(`[${name}] ${done}/${existing.ids.length}...`);
} catch (err: any) {
failed++;
console.warn(`[${name}] failed ${id}: ${err.message}`);
}
}
console.log(`[${name}] 완료 — ${done}개 재임베딩, ${failed}개 실패`);
}
async function main() {
await reembedCollection(USER_FACTS_COLLECTION);
await reembedCollection(DAILY_EXTRACTS_COLLECTION);
}
main().then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });
+165
View File
@@ -0,0 +1,165 @@
// Vector-based memory: embeddings via local Ollama (nomic-embed-text), storage/search via
// local Chroma (systemd --user service, port 8100). Sits alongside the flat USER.md/SOUL.md
// files — this is for the long-tail of facts that would otherwise get silently truncated by
// loadFile's fixed char caps (see server-v2.ts loadFile). Core identity stays in the small files;
// everything else that accumulates over time goes here and gets pulled in by relevance, not recency.
//
// Two collections (2026-07-24 design decision, see project_vector_memory_chroma memory):
// - USER_FACTS_COLLECTION: USER.md/SOUL.md-sourced facts via memory_write. Clean, high-trust.
// - DAILY_EXTRACTS_COLLECTION: LLM-extracted facts from raw daily conversation logs. Noisier —
// callers should apply a stricter relevance threshold against this one.
import { ChromaClient, type Collection } from 'chromadb';
import { getConfig } from '../config/config.js';
const CHROMA_HOST = 'localhost';
const CHROMA_PORT = 8100;
// EmbeddingGemma (300M, Gemma3-based) — switched from nomic-embed-text 2026-07-24: higher MTEB
// multilingual score (61.15 vs 53.01), genuinely multilingual-trained (better Korean semantics
// than nomic's English-centric training), and a smaller real footprint (~680MB loaded vs
// nomic's 274MB — still tiny, no GPU contention risk unlike the Qwen3-Embedding-8B path we
// tried and rejected — that one OOM'd SDXL image generation on GPU1 just from residing there).
// Kept resident (keep_alive below) since the footprint is negligible either way.
const EMBED_MODEL = 'embeddinggemma';
const EMBED_KEEP_ALIVE = '24h';
export const USER_FACTS_COLLECTION = 'homeclaw_memory';
export const DAILY_EXTRACTS_COLLECTION = 'daily_extracts';
function getOllamaEndpoint(): string {
return (getConfig().getConfig() as any).ollama?.endpoint || 'http://localhost:11434';
}
// EmbeddingGemma requires its own task-prefix convention (different from nomic's
// search_document:/search_query: — verified via Google's model card, 2026-07-24) or retrieval
// quality degrades the same way nomic's did without its prefix. Documents get "title: none |
// text: ", queries get "task: search result | query: ".
async function embedText(text: string, taskType: 'document' | 'query'): Promise<number[]> {
const prompt = taskType === 'document'
? `title: none | text: ${text}`
: `task: search result | query: ${text}`;
const res = await fetch(`${getOllamaEndpoint()}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: EMBED_MODEL, prompt, keep_alive: EMBED_KEEP_ALIVE }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`embedText failed: HTTP ${res.status}`);
const data: any = await res.json();
if (!Array.isArray(data.embedding)) throw new Error('embedText: no embedding in response');
return data.embedding;
}
// Exposed so callers querying multiple collections for the same turn (e.g. user_facts +
// daily_extracts) embed the query text once and reuse it, instead of one embed call per
// collection.
export async function embedQuery(text: string): Promise<number[]> {
return embedText(text, 'query');
}
let clientSingleton: ChromaClient | null = null;
const collectionCache = new Map<string, Collection>();
async function getCollection(name: string): Promise<Collection> {
const cached = collectionCache.get(name);
if (cached) return cached;
if (!clientSingleton) clientSingleton = new ChromaClient({ host: CHROMA_HOST, port: CHROMA_PORT });
// cosine space required — default (L2) ranked an unrelated fact above the relevant one
// in the same 2026-07-24 test that surfaced the prefix requirement.
const col = await clientSingleton.getOrCreateCollection({
name,
metadata: { 'hnsw:space': 'cosine' },
// We always supply embeddings explicitly (via Ollama) — without this, Chroma tries to
// instantiate its own DefaultEmbeddingFunction on every call and logs a noisy warning.
embeddingFunction: null,
});
collectionCache.set(name, col);
return col;
}
// Chroma requires a `where` clause to have exactly one top-level operator — a flat
// multi-key object like {workspace: X, date: Y} errors with "Expected 'where' to have
// exactly one operator, but got 2" (verified 2026-07-24). Callers write plain flat objects;
// this wraps them in $and automatically so they don't all need to know Chroma's where-clause
// quirks.
function toChromaWhere(where?: Record<string, string | number | boolean>): any {
if (!where) return undefined;
const keys = Object.keys(where);
if (keys.length <= 1) return where;
return { $and: keys.map(k => ({ [k]: where[k] })) };
}
export interface MemoryVectorRecord {
id: string;
text: string;
metadata?: Record<string, string | number | boolean>;
}
export async function addVector(collectionName: string, rec: MemoryVectorRecord): Promise<void> {
const col = await getCollection(collectionName);
const embedding = await embedText(rec.text, 'document');
await col.add({
ids: [rec.id],
embeddings: [embedding],
documents: [rec.text],
metadatas: rec.metadata ? [rec.metadata] : undefined,
});
}
export interface MemoryVectorHit {
text: string;
distance: number;
metadata?: Record<string, unknown>;
}
export async function queryVectorsWithEmbedding(
collectionName: string,
embedding: number[],
topK = 6,
where?: Record<string, string | number | boolean>,
): Promise<MemoryVectorHit[]> {
const col = await getCollection(collectionName);
const res = await col.query({ queryEmbeddings: [embedding], nResults: topK, where: toChromaWhere(where) });
const docs = res.documents?.[0] || [];
const dists = res.distances?.[0] || [];
const metas = res.metadatas?.[0] || [];
return docs.map((text, i) => ({
text: text || '',
distance: dists[i] ?? Infinity,
metadata: metas[i] as Record<string, unknown> | undefined,
}));
}
// Cheap existence check (no embedding call) — lets batch jobs like extract-daily-memory.ts
// skip already-processed items without needing to know their exact IDs up front.
export async function hasAnyVector(
collectionName: string,
where: Record<string, string | number | boolean>,
): Promise<boolean> {
const col = await getCollection(collectionName);
const res = await col.get({ where: toChromaWhere(where), limit: 1 });
return (res.ids?.length || 0) > 0;
}
export async function queryVectors(
collectionName: string,
query: string,
topK = 6,
where?: Record<string, string | number | boolean>,
): Promise<MemoryVectorHit[]> {
const embedding = await embedText(query, 'query');
return queryVectorsWithEmbedding(collectionName, embedding, topK, where);
}
// --- Backward-compatible convenience wrappers for the user_facts collection (memory_write) ---
export async function addMemoryVector(rec: MemoryVectorRecord): Promise<void> {
return addVector(USER_FACTS_COLLECTION, rec);
}
export async function queryMemoryVectors(
query: string,
topK = 6,
where?: Record<string, string | number | boolean>,
): Promise<MemoryVectorHit[]> {
return queryVectors(USER_FACTS_COLLECTION, query, topK, where);
}
+155
View File
@@ -0,0 +1,155 @@
// Hardcoded (regex-based) gates that force or block model behavior deterministically, as a
// backstop for cases where prompt-level instructions alone aren't reliably followed (e.g. a
// model skipping a required tool call and confidently fabricating an answer instead).
//
// EXEMPTIONS: verification-forcing gates (isFactualInfoRequest, looksLikeUnverifiedSpecClaim)
// must all agree on when NOT to force a search — otherwise fixing one gate's exemption still
// leaves the others forcing the same pointless retry. Verified 2026-07-24: fixing only
// isFactualInfoRequest (input-side) for personal-infra questions still left
// looksLikeUnverifiedSpecClaim (output-side) forcing a retry on the same case, since the
// personal-nickname exemption lived only in the first function. Route every such gate through
// isExemptFromVerification() below instead of duplicating the exemption regex per-gate.
// The user's own private infra nicknames — these describe personal hardware/servers, not a
// public product, so web_search can never resolve them (there's nothing to find out there).
// Add new private-only terms here — every verification-forcing gate picks it up automatically.
const EXEMPT_FROM_VERIFICATION = [
/지서버|클로서버|z-server|claw-server/i,
];
export function isExemptFromVerification(message: string): boolean {
const m = String(message || '');
return EXEMPT_FROM_VERIFICATION.some(re => re.test(m));
}
export function isGreetingLikeMessage(text: string): boolean {
const raw = String(text || '').trim();
if (!raw || raw.length > 120) return false;
if (/\b(search|open|read|write|file|code|task|build|fix|debug|run|install|http|www\.|\.com|please|could you|can you)\b/i.test(raw)) {
return false;
}
return /^(hi|hello|hey|yo|sup|howdy|good (morning|afternoon|evening)|hey claw|hello claw|hi claw|hey smallclaw|hello smallclaw|hi smallclaw|how are you)[!.?\s]*$/i.test(raw);
}
export function isExecutionLikeRequest(message: string): boolean {
const m = String(message || '');
return /\b(create|build|implement|develop|scaffold|generate|fix|debug|edit|update|refactor|rewrite|patch|setup|configure|calendar|app|component|project|file|folder|directory|workspace|code|desktop|window|screen|mouse|keyboard|clipboard|vs code|vscode)\b/i.test(m)
|| /(만들어|생성해|구현해|개발해|고쳐|수정해|편집해|디버그|리팩터|리팩토링|패치|설정해|프로젝트|파일|폴더|디렉터리|워크스페이스|코드|바탕화면|화면|마우스|키보드|클립보드)/.test(m);
}
export function isLiveDataRequest(message: string): boolean {
const m = String(message || '');
return /(뉴스|속보|날씨|기온|예보|미세먼지|황사|환율|주가|금리|시세|장마)/.test(m)
|| /\b(news|weather|forecast|stock price|exchange rate)\b/i.test(m);
}
// Broader than isLiveDataRequest: catches factual/product-info questions (specs, prices,
// versions, comparisons, "what models exist") where the model tends to answer fluently from
// memorized training data instead of checking — those numbers go stale or are just wrong
// (e.g. confidently inventing GPU TFLOPS/VRAM figures). Deliberately excludes anything that
// looks like a coding/build request or a greeting, since those aren't "look this up" asks.
export function isFactualInfoRequest(message: string): boolean {
const m = String(message || '').trim();
if (!m) return false;
if (isExecutionLikeRequest(m) || isGreetingLikeMessage(m)) return false;
if (isExemptFromVerification(m)) return false;
return /\b(vs\.?|versus)\b/i.test(m)
|| /(비교|차이점?|스펙|사양|성능|가격|버전|몇\s*(세대|개|년|만원)|추천해|어떤\s*(게|것|모델|제품|카드)|뭐가\s*있|무엇이\s*있|종류가)/.test(m);
}
// Output-side counterpart to isFactualInfoRequest()/isLiveDataRequest(): those only catch
// predictable INPUT phrasings ("스펙", "비교", "몇 세대"), and casual paraphrases slip through
// unmatched ("초당 몇 토큰 나올까?", "이거 어떨까?") — a model answering those confidently from
// memory ("약 70~80GB", "31 tok/s가 나옵니다") never gets flagged even though it's the exact
// fabrication ANTI-HALLUCINATION warns about. This instead scans what the model actually WROTE
// for a concrete spec/price/benchmark number (digit + a spec-like unit) or a Korean hedge
// phrase ("~것으로 예상", "추정") — a much smaller, harder-to-dodge surface than every possible
// question wording. Skips code fences so numeric literals in code don't false-positive.
// Caller must separately check isExemptFromVerification(message) — a personal-infra answer
// legitimately contains GB/tok-s-shaped numbers and shouldn't force a retry just for that.
export function looksLikeUnverifiedSpecClaim(content: string): boolean {
const text = String(content || '');
if (!text) return false;
const withoutCode = text.replace(/```[\s\S]*?```/g, '');
return /\d[\d,.]*\s*(GB|TB|MB|tok\/s|tps|TFLOPS?|GFLOPS?|GHz|MHz|watts?|원|달러|USD|\$|토큰\s*\/\s*초)/i.test(withoutCode)
|| /초당\s*(약\s*)?\d/.test(withoutCode)
|| /(것으로\s*예상|추정됩니다|추정치|짐작)/.test(withoutCode);
}
// --- Step 2 (2026-07-24): browser/desktop/messaging-completion gates, moved alongside the
// verification gates above for the same reason — one place to scan for the full set of
// hardcoded behaviors instead of hunting through server-v2.ts.
export function isBrowserAutomationRequest(message: string): boolean {
const m = String(message || '');
const hasBrowserVerb = /\b(open|go to|navigate|visit|browse|click|type|fill|press|submit|log ?in|login|use my computer)\b/i.test(m)
|| /(열어|들어가|접속해|방문해|클릭해|눌러|입력해|채워|제출해|로그인해)/.test(m);
const hasTarget = /(?:https?:\/\/)?(?:www\.)?[a-z0-9][a-z0-9.-]+\.[a-z]{2,}(?:\/\S*)?/i.test(m)
|| /\b(chatgpt|google|reddit|x\.com|twitter|github|youtube|chrome)\b/i.test(m)
|| /(네이버|다음|사이트)/.test(m);
return hasBrowserVerb && hasTarget;
}
export function isDesktopAutomationRequest(message: string): boolean {
const m = String(message || '');
const hasDesktopVerb = /\b(check|look|see|open|focus|click|type|press|read|copy|paste|use my computer|screenshot)\b/i.test(m)
|| /(확인해|봐줘|열어|클릭해|눌러|입력해|읽어|복사해|붙여넣|스크린샷|캡처)/.test(m);
const hasDesktopTarget = /\b(desktop|screen|window|app|application|vs code|vscode|terminal|notepad|clipboard|codex)\b/i.test(m)
|| /(바탕화면|화면|창|어플|터미널|메모장|클립보드)/.test(m);
const statusAsk = /\b(is|did|has).*\b(done|finished|complete|completed)\b/i.test(m)
|| /(됐|끝났|완료|다\s*됐|다\s*끝났)/.test(m);
return (hasDesktopVerb && hasDesktopTarget) || (statusAsk && /\b(vs code|vscode|codex)\b/i.test(m));
}
// A grounding tool call that "succeeded" at the API level but came back with zero real
// content (empty stdout, or one of the known "no results" sentinel strings) is functionally
// the same as no grounding call at all — but the AUTO-RECOVER logic only checks whether a
// grounding tool WAS called, not whether it returned anything usable. Left unchecked, a model
// facing an all-empty turn (e.g. a region NewsData.io has no coverage for, or a Korean query
// string that can't match non-Korean-language articles) still answers fluently from training
// data and cites sources as if they came from the search it just ran.
export function isUsableGroundingResult(r: { error?: boolean; result?: string }): boolean {
if (r.error) return false;
const text = String(r.result || '').trim();
if (!text) return false;
if (/^\(no articles found\)$/i.test(text)) return false;
if (/에\s*대한\s*검색\s*결과가\s*없습니다\.?$/i.test(text)) return false;
return true;
}
export function looksLikeSafetyRefusal(text: string): boolean {
const s = String(text || '').trim().toLowerCase();
if (!s) return false;
return (
/disallowed|can't (help|assist|do that|use your computer)|cannot (help|assist|do that|use your computer)|unable to (help|assist|do that)/i.test(s)
|| /i (can't|cannot) (control|operate|use) (your|the) computer/i.test(s)
|| /against (policy|safety)/i.test(s)
);
}
export function hasConcreteCompletion(text: string): boolean {
const s = String(text || '').trim();
if (!s) return false;
return /\b(done|completed|created|updated|fixed|implemented|finished|saved|wrote|executed|here(?:'s| is) (?:the|your)|success(?:fully)?)\b/i.test(s);
}
export function isMessagingRequest(message: string): boolean {
const m = String(message || '');
return (/(이메일|메일)/.test(m) && /(보내|전송|발송)/.test(m))
|| /(카카오톡?|카톡)/.test(m) && /(보내|전송|발송)/.test(m)
|| /\b(send)\b.*\b(email|mail|kakao)\b/i.test(m);
}
export function claimsMessageSent(text: string): boolean {
const s = String(text || '');
return /(보냈습니다|보냈어요|전송했습니다|전송했어요|발송했습니다|발송했어요|보내드렸습니다|전송\s*완료|발송\s*완료)/.test(s)
|| /\b(sent the (email|message)|email (has been|was) sent|message (has been|was) sent)\b/i.test(s);
}
export function isBrowserToolName(name: string): boolean {
return /^browser_(open|snapshot|click|fill|press_key|wait|scroll|close)$/i.test(String(name || ''));
}
export function isDesktopToolName(name: string): boolean {
return /^desktop_(screenshot|find_window|focus_window|click|drag|wait|type|press_key|get_clipboard|set_clipboard)$/i.test(String(name || ''));
}
+87 -116
View File
@@ -16,6 +16,31 @@ import fs from 'fs';
import fsp from 'fs/promises';
import crypto from 'crypto';
import { WebSocketServer, WebSocket } from 'ws';
import {
addMemoryVector,
queryMemoryVectors,
embedQuery,
queryVectorsWithEmbedding,
USER_FACTS_COLLECTION,
DAILY_EXTRACTS_COLLECTION,
} from './memory-vector';
import {
isGreetingLikeMessage,
isExecutionLikeRequest,
isLiveDataRequest,
isFactualInfoRequest,
looksLikeUnverifiedSpecClaim,
isExemptFromVerification,
isBrowserAutomationRequest,
isDesktopAutomationRequest,
isUsableGroundingResult,
looksLikeSafetyRefusal,
hasConcreteCompletion,
isMessagingRequest,
claimsMessageSent,
isBrowserToolName,
isDesktopToolName,
} from './prompt-gates';
import { registerArduinoRoutes } from './routes-arduino';
import { registerAndroidRoutes, attachAndroidWsProxy } from './routes-android';
import { registerPptxRoutes } from './routes-pptx';
@@ -1591,6 +1616,39 @@ async function buildPersonalityContext(
// SOUL only when tool categories are detected — pure conversational turns don't need it
const soulContent = cats.size > 0 ? loadFile('SOUL.md', 4000) : '';
// Vector-store recall: relevance-based, not the fixed-recency/fixed-char-cap USER.md/SOUL.md
// path — this is what lets facts survive past loadFile's truncation cutoff (see loadFile
// above). Scoped to this workspace via the `where` filter so users never see each other's
// facts. Best-effort — a Chroma/embedding outage must not break prompt building.
//
// Two collections, one query embedding (2026-07-24 design — see project_vector_memory_chroma
// memory): user_facts (memory_write-sourced, clean) gets a loose threshold; daily_extracts
// (LLM-extracted from raw conversation logs, noisier) gets a stricter one. Both merged and
// ranked together by distance so the model just sees one flat, relevance-sorted list.
let vectorMemory = '';
if (messageText.trim().length >= 4) {
try {
const queryEmbedding = await embedQuery(messageText);
const [userFactHits, dailyExtractHits] = await Promise.all([
queryVectorsWithEmbedding(USER_FACTS_COLLECTION, queryEmbedding, 6, { workspace: workspacePath }),
queryVectorsWithEmbedding(DAILY_EXTRACTS_COLLECTION, queryEmbedding, 6, { workspace: workspacePath }),
]);
// Thresholds recalibrated for EmbeddingGemma's wider distance spread (2026-07-24 switch
// from nomic-embed-text) — a 3-fact spot check gave ~0.51 for a genuinely relevant match,
// ~0.66 for same-topic-wrong-entity, ~0.97 for unrelated. Rough starting points, not a
// rigorous calibration — revisit if recall feels off/noisy in real use.
const relevant = [
...userFactHits.filter(h => h.distance < 0.65),
...dailyExtractHits.filter(h => h.distance < 0.55 && !h.metadata?.empty),
].sort((a, b) => a.distance - b.distance).slice(0, 8);
if (relevant.length > 0) {
vectorMemory = relevant.map(h => `- ${h.text}`).join('\n');
}
} catch (err: any) {
console.warn('[buildPersonalityContext] vector memory query failed (non-fatal):', err.message);
}
}
const parts = [
loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '',
loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '',
@@ -1599,6 +1657,7 @@ async function buildPersonalityContext(
intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '',
toolBlockParts.length > 0 ? `[TOOLS]\n${toolBlockParts.join('\n\n')}${toolsHint}` : (toolsHint ? `[TOOLS]${toolsHint}` : ''),
memorySnippets ? `[RELEVANT_MEMORY]\n${memorySnippets}` : '',
vectorMemory ? `[RECALLED_FACTS]\n${vectorMemory}` : '',
self ? `[SELF]\n${self}` : '',
].filter(Boolean);
@@ -4423,6 +4482,14 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
mwFileContent = mwFileContent.slice(0, insertAt) + '\n\n' + mwSectionHeader + '\n' + mwEntry + mwFileContent.slice(insertAt);
}
fs.writeFileSync(mwPath, mwFileContent, 'utf-8');
// Best-effort: also index in the vector store so it survives USER.md/SOUL.md's fixed
// char-cap truncation and can be retrieved by relevance later, not just recency. Never
// let a Chroma/embedding hiccup fail the primary (file) write.
addMemoryVector({
id: `${workspacePath}:${mwFile}:${mwCategory}:${Date.now()}`,
text: mwContent,
metadata: { workspace: workspacePath, file: mwFile, category: mwCategory, date: mwDate },
}).catch(err => console.warn('[memory_write] vector index failed (non-fatal):', err.message));
return { name, args, result: `Written to ${mwFilename} [${mwCategory}]: ${mwContent}`, error: false };
}
@@ -4657,15 +4724,6 @@ function normalizeForDedup(text: string): string {
return raw.replace(/[^a-z0-9]+/g, '');
}
function isGreetingLikeMessage(text: string): boolean {
const raw = String(text || '').trim();
if (!raw || raw.length > 120) return false;
if (/\b(search|open|read|write|file|code|task|build|fix|debug|run|install|http|www\.|\.com|please|could you|can you)\b/i.test(raw)) {
return false;
}
return /^(hi|hello|hey|yo|sup|howdy|good (morning|afternoon|evening)|hey claw|hello claw|hi claw|hey smallclaw|hello smallclaw|hi smallclaw|how are you)[!.?\s]*$/i.test(raw);
}
function sanitizeFinalReply(
text: string,
opts: { preflightReason?: string } = {},
@@ -4751,33 +4809,6 @@ function stripExplicitThinkTags(text: string): { cleaned: string; thinking: stri
}
function isExecutionLikeRequest(message: string): boolean {
const m = String(message || '');
return /\b(create|build|implement|develop|scaffold|generate|fix|debug|edit|update|refactor|rewrite|patch|setup|configure|calendar|app|component|project|file|folder|directory|workspace|code|desktop|window|screen|mouse|keyboard|clipboard|vs code|vscode)\b/i.test(m)
|| /(만들어|생성해|구현해|개발해|고쳐|수정해|편집해|디버그|리팩터|리팩토링|패치|설정해|프로젝트|파일|폴더|디렉터리|워크스페이스|코드|바탕화면|화면|마우스|키보드|클립보드)/.test(m);
}
function isBrowserAutomationRequest(message: string): boolean {
const m = String(message || '');
const hasBrowserVerb = /\b(open|go to|navigate|visit|browse|click|type|fill|press|submit|log ?in|login|use my computer)\b/i.test(m)
|| /(열어|들어가|접속해|방문해|클릭해|눌러|입력해|채워|제출해|로그인해)/.test(m);
const hasTarget = /(?:https?:\/\/)?(?:www\.)?[a-z0-9][a-z0-9.-]+\.[a-z]{2,}(?:\/\S*)?/i.test(m)
|| /\b(chatgpt|google|reddit|x\.com|twitter|github|youtube|chrome)\b/i.test(m)
|| /(네이버|다음|사이트)/.test(m);
return hasBrowserVerb && hasTarget;
}
function isDesktopAutomationRequest(message: string): boolean {
const m = String(message || '');
const hasDesktopVerb = /\b(check|look|see|open|focus|click|type|press|read|copy|paste|use my computer|screenshot)\b/i.test(m)
|| /(확인해|봐줘|열어|클릭해|눌러|입력해|읽어|복사해|붙여넣|스크린샷|캡처)/.test(m);
const hasDesktopTarget = /\b(desktop|screen|window|app|application|vs code|vscode|terminal|notepad|clipboard|codex)\b/i.test(m)
|| /(바탕화면|화면|창|어플|터미널|메모장|클립보드)/.test(m);
const statusAsk = /\b(is|did|has).*\b(done|finished|complete|completed)\b/i.test(m)
|| /(됐|끝났|완료|다\s*됐|다\s*끝났)/.test(m);
return (hasDesktopVerb && hasDesktopTarget) || (statusAsk && /\b(vs code|vscode|codex)\b/i.test(m));
}
function extractLikelyUrl(message: string): string | null {
const raw = String(message || '');
const directUrlMatch = raw.match(/\bhttps?:\/\/[^\s)]+/i);
@@ -4788,48 +4819,12 @@ function extractLikelyUrl(message: string): string | null {
return normalized.replace(/["'<>]/g, '');
}
function isLiveDataRequest(message: string): boolean {
const m = String(message || '');
return /(뉴스|속보|날씨|기온|예보|미세먼지|황사|환율|주가|금리|시세|장마)/.test(m)
|| /\b(news|weather|forecast|stock price|exchange rate)\b/i.test(m);
}
// Broader than isLiveDataRequest: catches factual/product-info questions (specs, prices,
// versions, comparisons, "what models exist") where the model tends to answer fluently from
// memorized training data instead of checking — those numbers go stale or are just wrong
// (e.g. confidently inventing GPU TFLOPS/VRAM figures). Deliberately excludes anything that
// looks like a coding/build request or a greeting, since those aren't "look this up" asks.
function isFactualInfoRequest(message: string): boolean {
const m = String(message || '').trim();
if (!m) return false;
if (isExecutionLikeRequest(m) || isGreetingLikeMessage(m)) return false;
return /\b(vs\.?|versus)\b/i.test(m)
|| /(비교|차이점?|스펙|사양|성능|가격|버전|몇\s*(세대|개|년|만원)|추천해|어떤\s*(게|것|모델|제품|카드)|뭐가\s*있|무엇이\s*있|종류가)/.test(m);
}
// Per-model behavioral overrides. Some models (e.g. mistral-large-3) have observed quirks —
// skipping tool calls and confidently fabricating instead — that the generic system prompt
// doesn't fully correct. Rather than hardcoding model names in prompt text, config.json's
// models.profiles[modelName].extraSystemPrompt lets us attach model-specific reminders that
// only apply when that exact model is the one actually serving the turn, discovered/edited
// without a code deploy.
// A grounding tool call that "succeeded" at the API level but came back with zero real
// content (empty stdout, or one of the known "no results" sentinel strings) is functionally
// the same as no grounding call at all — but the existing AUTO-RECOVER logic only checks
// whether a grounding tool WAS called, not whether it returned anything usable. Left
// unchecked, a model facing an all-empty turn (e.g. a region NewsData.io has no coverage
// for, or a Korean query string that can't match non-Korean-language articles) still answers
// fluently from training data and cites sources as if they came from the search it just ran —
// reads as authoritative, which is worse than an honest "no results found".
function isUsableGroundingResult(r: { error?: boolean; result?: string }): boolean {
if (r.error) return false;
const text = String(r.result || '').trim();
if (!text) return false;
if (/^\(no articles found\)$/i.test(text)) return false;
if (/에\s*대한\s*검색\s*결과가\s*없습니다\.?$/i.test(text)) return false;
return true;
}
function getModelProfileExtraPrompt(modelName: string): string {
const m = String(modelName || '').trim();
if (!m) return '';
@@ -4842,16 +4837,6 @@ function getModelProfileExtraPrompt(modelName: string): string {
}
}
function looksLikeSafetyRefusal(text: string): boolean {
const s = String(text || '').trim().toLowerCase();
if (!s) return false;
return (
/disallowed|can't (help|assist|do that|use your computer)|cannot (help|assist|do that|use your computer)|unable to (help|assist|do that)/i.test(s)
|| /i (can't|cannot) (control|operate|use) (your|the) computer/i.test(s)
|| /against (policy|safety)/i.test(s)
);
}
function looksLikeIntentOnlyReply(text: string): boolean {
const s = String(text || '').trim();
if (!s) return true;
@@ -4864,33 +4849,6 @@ function looksLikeIntentOnlyReply(text: string): boolean {
return intentPattern.test(s);
}
function hasConcreteCompletion(text: string): boolean {
const s = String(text || '').trim();
if (!s) return false;
return /\b(done|completed|created|updated|fixed|implemented|finished|saved|wrote|executed|here(?:'s| is) (?:the|your)|success(?:fully)?)\b/i.test(s);
}
function isMessagingRequest(message: string): boolean {
const m = String(message || '');
return (/(이메일|메일)/.test(m) && /(보내|전송|발송)/.test(m))
|| /(카카오톡?|카톡)/.test(m) && /(보내|전송|발송)/.test(m)
|| /\b(send)\b.*\b(email|mail|kakao)\b/i.test(m);
}
function claimsMessageSent(text: string): boolean {
const s = String(text || '');
return /(보냈습니다|보냈어요|전송했습니다|전송했어요|발송했습니다|발송했어요|보내드렸습니다|전송\s*완료|발송\s*완료)/.test(s)
|| /\b(sent the (email|message)|email (has been|was) sent|message (has been|was) sent)\b/i.test(s);
}
function isBrowserToolName(name: string): boolean {
return /^browser_(open|snapshot|click|fill|press_key|wait|scroll|close)$/i.test(String(name || ''));
}
function isDesktopToolName(name: string): boolean {
return /^desktop_(screenshot|find_window|focus_window|click|drag|wait|type|press_key|get_clipboard|set_clipboard)$/i.test(String(name || ''));
}
function isHighStakesFile(filename: string): boolean {
const f = String(filename || '').toLowerCase();
return /(auth|billing|payment|security|secret|token|config|credential|oauth|permission|acl)/.test(f);
@@ -5914,14 +5872,14 @@ async function handleChat(
{
role: 'system',
content: isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw, a local AI assistant. Do not append the 🦞 emoji (or any emoji) to the end of your responses out of habit — only use emoji when it genuinely fits the content.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know. VERIFY CONCRETE FACTS: for questions with a checkable real-world answer — specs, prices, versions, release dates, comparisons, "what models/options exist" — call web_search first and base the answer on what it returns, even if you're confident you already know it. Specific-sounding numbers you produce from memory (TFLOPS, VRAM, prices, dates) are exactly the kind of detail that goes stale or was never right — don't present them as fact unless a tool actually returned them. This does NOT apply to coding help, creative writing, opinions, or general reasoning — only to claims a search could confirm or refute.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know. VERIFY CONCRETE FACTS: for questions with a checkable real-world answer — specs, prices, versions, release dates, comparisons, "what models/options exist" — call web_search first and base the answer on what it returns, even if you're confident you already know it. Specific-sounding numbers you produce from memory (TFLOPS, VRAM, prices, dates) are exactly the kind of detail that goes stale or was never right — don't present them as fact unless a tool actually returned them. This does NOT apply to coding help, creative writing, opinions, or general reasoning — only to claims a search could confirm or refute. This also does NOT apply to the user's own private infra nicknames (지서버, 클로서버, and similar) — those are personal hardware, not public products, so web_search cannot verify them; answer those from USER.md/SOUL.md/[RECALLED_FACTS] context instead.
TEMPORAL CONSISTENCY: The current date and day-of-week is given above ("Current date: ${dateStr}") — this is ground truth, more reliable than any search snippet's phrasing. Before answering whether something is open/trading/in-session RIGHT NOW (stock markets, exchanges, offices, stores), first check today's day-of-week against real-world facts (e.g. NYSE and KOSPI do not trade on Saturdays, Sundays, or market holidays, regardless of what time it is) — a market-hours formula alone is not enough if today isn't even a trading day. A search result reporting a "closing price" or "today's headline" is not proof today is a trading/business day — cross-check it against the actual current date above, and if they conflict (e.g. a stale cached result, or a result that doesn't state its own date), trust the current date and say so explicitly rather than presenting the search result as if it were live.
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
IMAGE/VIDEO GENERATION: When a user asks to create/draw/generate a NEW image from a description (no existing photo involved), call image_generate (local SDXL, 10-20 seconds by default). If the user explicitly asks for higher quality/detail/photorealism (e.g. "고품질로", "디테일 살려서"), pass quality="high" instead — this uses FLUX.1-schnell, noticeably better detail but 40-60 seconds total, so tell the user it'll take a bit before calling it. When they ask for a short video/clip/animation from a description, call video_generate (local LTX-Video, 30–90 seconds — tell the user it'll take a bit before calling it). All run entirely on local GPU hardware, no external API or cost. Do not confuse these with image_edit, which only modifies an existing uploaded/generated image.${browserRuleBlock}
CHEMISTRY NOTATION: NEVER draw molecular structures as ASCII art (H/C/#/=/\\/| characters arranged to look like a diagram) — it always renders as garbled, misaligned text. Instead: for a formula or reaction, use LaTeX inside $...$ (e.g. $\\ce{C4H10}$, $\\ce{2H2 + O2 -> 2H2O}$ — mhchem extension is loaded). For an actual 2D structure with real bond lines (rings, branches), output a \`\`\`smiles\`\`\` code block containing the SMILES string (e.g. \`\`\`smiles\\nc1ccccc1\\n\`\`\` for benzene, \`\`\`smiles\\nCC(=O)Oc1ccccc1C(=O)O\\n\`\`\` for aspirin) — the client automatically renders it as a proper 2D diagram with bond lines.
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use coder_overwrite_lines or coder_insert_lines (not coder_write_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.
OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. Example: news → table with columns 제목|요약|출처.${modelProfileBlock}${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`,
OUTPUT FORMAT: When presenting 3+ items (emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. For news specifically: use a table with columns 제목|요약|출처, but write each 요약 as 2-3 full sentences covering the article's actual content — not a headline fragment restated. For weather: do NOT force a table — explain current conditions and forecast in natural, fuller sentences (trend, precipitation chance, notable changes vs yesterday/normal), not just bare numbers.${modelProfileBlock}${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`,
},
];
// Emit the fixed-overhead size (system prompt + tool schemas) as soon as both are known —
@@ -5970,13 +5928,13 @@ OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results,
messages.push({ role: 'assistant', content: 'Got it — checking now.' });
messages.push({
role: 'user',
content: `Reminder: this needs live/current data. Call a tool first (${isNewsRequest ? 'news_search for news' : 'web_search for news'}, weather_kma/weather_search for weather/forecast, etc.) before writing anything. Do NOT answer from memory or training data.${isNewsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search with no query for general headlines — do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' : ''}`,
content: `Reminder: this needs live/current data. Call a tool first (${isNewsRequest ? 'news_search for news' : 'web_search for news'}, weather_kma/weather_search for weather/forecast, etc.) before writing anything. Do NOT answer from memory or training data.${isNewsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' : ''}`,
});
} else if (isFactualInfoRequest(message)) {
messages.push({ role: 'assistant', content: 'Got it — let me verify that first.' });
messages.push({
role: 'user',
content: 'Reminder: this asks about concrete facts (specs, prices, versions, comparisons, or "what models/options exist"). Call web_search first to verify before answering — do not rely on memorized details, which are frequently stale or simply wrong. If search turns up nothing useful, say so explicitly rather than filling the gap from memory.',
content: 'Reminder: this asks about concrete facts (specs, prices, versions, comparisons, or "what models/options exist"). For a PUBLIC product/fact, call web_search first to verify before answering — do not rely on memorized details, which are frequently stale or simply wrong. But if this is about the user\'s OWN personal setup (their hardware nicknames like 지서버/클로서버, their own servers/accounts/configs — anything that only exists in USER.md/SOUL.md/[RECALLED_FACTS], not a public product), that is NOT web-searchable — check memory (USER.md, [RECALLED_FACTS] context, memory_read) first instead of wasting a search call on it. Only fall back to web_search for the personal-setup case if memory genuinely has nothing on it. If search turns up nothing useful for a public fact, say so explicitly rather than filling the gap from memory.',
});
}
@@ -7483,7 +7441,15 @@ RULES:
// even when the model's reply looks like a confident final answer rather than stalled
// reasoning (a model that skips tools and answers fluently is more dangerous than one
// that visibly hesitates, since the fabrication reads as authoritative).
const liveDataRequest = isLiveDataRequest(message) || isFactualInfoRequest(message);
// Output-side catch-all: the input didn't match any known "factual question" phrasing,
// but the model went ahead and stated a spec/price/benchmark number anyway. Excludes
// execution-like requests (coding, file ops) so numbers in those contexts don't misfire.
// Same personal-infra exemption as isFactualInfoRequest (see PERSONAL_INFRA_NICKNAMES) —
// this is the OUTPUT-side counterpart and was missing it: a 지서버/클로서버 hardware answer
// legitimately contains GB/tok/s-shaped numbers, which would otherwise still force a
// pointless web_search retry even after the input-side gate was fixed for the same case.
const unverifiedSpecClaim = !isExecutionLikeRequest(message) && !isExemptFromVerification(message) && looksLikeUnverifiedSpecClaim(content);
const liveDataRequest = isLiveDataRequest(message) || isFactualInfoRequest(message) || unverifiedSpecClaim;
// Whether a tool that could actually ground THIS kind of request has already run —
// any other tool call (file listing, email check, etc.) doesn't count.
const groundingToolPattern = browserAutomationRequest
@@ -7494,7 +7460,12 @@ RULES:
const hasGroundingToolCall = allToolResults.some((r) => groundingToolPattern.test(String(r?.name || '')));
if (((queryNeedsTools && (looksLikeReasoning || looksLikeRefusal)) || liveDataRequest) && !hasGroundingToolCall) {
toolSkipForcedRetries++;
console.log(`[v2] AUTO-RECOVER (${toolSkipForcedRetries}/${MAX_TOOL_SKIP_FORCED_RETRIES}): Model dumped ${content.length} chars${liveDataRequest ? ' (live-data query answered without a grounding tool call)' : ' of reasoning instead of calling tools'}${allToolResults.length > 0 ? ` [${allToolResults.length} unrelated tool call(s) already made this turn]` : ''}. Re-prompting...`);
const liveDataReason = unverifiedSpecClaim
? ' (unverified spec/price/benchmark number in the answer, no matching input phrasing)'
: liveDataRequest
? ' (live-data query answered without a grounding tool call)'
: '';
console.log(`[v2] AUTO-RECOVER (${toolSkipForcedRetries}/${MAX_TOOL_SKIP_FORCED_RETRIES}): Model dumped ${content.length} chars${liveDataReason || ' of reasoning instead of calling tools'}${allToolResults.length > 0 ? ` [${allToolResults.length} unrelated tool call(s) already made this turn]` : ''}. Re-prompting...`);
allThinking += (allThinking ? '\n\n' : '') + content;
sendSSE('thinking', { thinking: content.slice(0, 500) + '...' });
// Inject a forceful nudge and retry this round
@@ -7535,7 +7506,7 @@ RULES:
? 'the news_search tool (NOT web_search)'
: 'the web_search tool';
const scopeHint = isNewsRequest
? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search with no query for general headlines — do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.'
? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.'
: '';
messages.push({ role: 'assistant', content: 'Let me check that now.' });
messages.push({ role: 'user', content: `Yes, use ${toolHint} right now. Do NOT think or plan — just call it.${scopeHint}` });