You are working in Redstring, a React/Zustand graph-of-graphs knowledge tool (see CLAUDE.md and AI_COMPENDIUM.md for architecture). This task is the first implementation step of a larger direction: replacing brittle heuristics with one-off small-language-model calls — single, tiny, constrained questions (pick from a list, yes/no, one short label), no agent loop, designed to work well with small local models (Ollama / LM Studio on Apple silicon) as well as cloud models.
The codebase makes judgment-like decisions all over the place using substring matching, Levenshtein distance, keyword lists, and magic thresholds. Each of these is really a tiny multiple-choice question a small model answers reliably. We are NOT building agents here — each call is stateless: small input → constrained output → validated by code → done.
An audit found the full set of opportunities. This handoff covers the shared utility plus the first three integrations.
Create src/services/oneShot.js. Requirements:
- Reuse existing plumbing:
src/services/apiKeyManager.jsalready supports Anthropic, OpenAI, OpenRouter, and local Ollama/LM Studio endpoints (~lines 337-416). Do not build new provider code. - Constrained-output API, something like:
oneShotChoice({ instruction, input, options })→ returns one ofoptions(by index) ornulloneShotBoolean({ instruction, input })→true | false | nulloneShotLabel({ instruction, input, maxWords })→ short string ornull
- Strict parsing: the model's raw text is parsed against the expected shape; anything malformed returns
null. Never throw into caller code. - Graceful degradation is mandatory: if no model is configured, the call times out (default ~3s for interactive paths), or parsing fails → return
null, and every caller falls back to its current heuristic behavior. The app must work identically with zero models configured. - Log every call — this is critical, not optional. Every one-shot call appends a record:
{ timestamp, callSite, instruction, input, rawResponse, parsedResult, latencyMs }. Callers can later attach an outcome (accepted/rejected/editedby the user). Store as JSONL (localStorage ring buffer or file via existing persistence — keep it simple). This log is the training-data pipeline for future fine-tuning; it must exist from day one. - Small-model-friendly prompts: instructions of a few sentences max, options presented as a numbered list, ask for the number only. No JSON output from the model unless unavoidable — prefer "answer with the number" / "answer yes or no".
src/wizard/tools/ are imported by redstring-mcp-server.js. If oneShot.js is ever imported from there, it must NEVER use console.log (corrupts stdio transport) — use console.error for all logging.
src/services/aiDuplicateDetector.js is a complete, working LLM dedup implementation that is dead code — imported nowhere.
- Wire it into
src/components/DuplicateManager.jsx(currently usesfindPotentialDuplicatesfromgraphStore.js~line 1860, pure Levenshtein at 0.8 threshold) so candidate pairs get a semantic yes/no verdict. - Optionally also into
addNodePrototypeWithDeduplication(graphStore.js~1798) for dedup-at-creation. - Fix its prefilter:
aiDuplicateDetector.js~lines 28-40 pre-filters candidates with a bigram similarity > 0.3 cutoff before the LLM sees them — which filters out exactly the synonym cases ("NYC" / "New York City") the LLM exists to catch. Loosen or make recall-oriented. - Keep the Levenshtein path as the fallback when no model is available.
The same broken pattern (exact match → substring match → arbitrary first/last pick) is hand-rolled in at least six places, and it silently corrupts graphs:
src/wizard/tools/createEdge.js~lines 10-67 (keeps LAST substring match)src/wizard/tools/updateNode.js~lines 10-42 (keeps FIRST)src/wizard/tools/deleteNode.js~lines 10-39 (destructive op on a fuzzy guess!)src/wizard/tools/setNodeType.js~lines 14-45src/wizard/tools/selectNode.js~lines 57-132 (ad-hoc point scoring)src/wizard/tools/edgeValidator.js~lines 15-62 (silently DROPS edges whose endpoints aren't exact name matches — silent data loss)
Build ONE shared resolver, e.g. src/wizard/tools/utils/resolveNodeSmart.js:
- Exact case-insensitive match short-circuits deterministically — no model call. (Per project convention: when multiple prototypes share a name, take the LAST match — Maps iterate oldest-first and old prototypes accumulate.)
- Ambiguous or fuzzy cases →
oneShotChoice: input = the mentioned name + numbered candidate list (name + short description), output = one index or "none". null/no-model fallback → current substring behavior, unchanged.- Special rule for destructive ops (
deleteNode): never act on a fuzzy/model-resolved match silently — if resolution wasn't exact, the tool result should say what it resolved to so the agent/user can confirm. - Replace the edgeValidator drop with: unmatched endpoint → one resolution call → only drop if truly unresolvable, and report dropped edges in the tool result instead of silently discarding.
Remember: console.error only in these files (MCP stdio rule).
When the user draws a connection, they get a blank field (src/NodeCanvas.jsx — connectionNamePrompt state ~line 3184, rendered ~8213-8330; the current live path is the UnifiedSelector mode="connection-creation" ~line 15075).
- On prompt open, fire
oneShotChoice/oneShotLabelin the background: input = source node name + target node name (+ descriptions if short), plus the existing connection-prototype names as options. Output = one existing connection type OR a new short verb phrase ("directed by", "is a kind of"). - Pre-fill as a suggestion the user can overwrite — never auto-commit. If the call hasn't returned by the time the user types, discard it (user input always wins).
- Log accepted/edited/ignored as the outcome on the call record.
- No agent loops, no fine-tuning, no embeddings, no vector stores. One-off calls only.
- Don't refactor AgentLoop.js or the existing wizard conversation flow.
- Plain JavaScript (no TypeScript syntax). Match surrounding code style.
- Zustand: all state changes through store actions; store uses Maps (mind serialization).
- Every integration must be a strict improvement with a heuristic fallback — the app with no model configured behaves exactly as today.
- Line numbers above are approximate — verify before editing.
- Test with a local model via LM Studio/Ollama (OpenAI-compatible endpoint, already supported by apiKeyManager) — that's the target deployment, not cloud.
- With no model configured: all touched flows behave identically to before (fallbacks work).
- With a local model: "NYC" vs "New York City" flagged as duplicates; "membrane" resolving against {Outer Membrane, Membrane Potential} asks the model and picks correctly or returns none; drawing an edge between "Kubrick" and "2001: A Space Odyssey" pre-fills something like "directed".
- edgeValidator no longer silently drops near-miss edges; dropped edges are reported.
- The JSONL call log exists and records every call with latency and outcome.
- Existing tests pass; add tests for
oneShotparsing/fallback andresolveNodeSmart(exact-match short-circuit, last-match rule, null fallback).
This utility is the beachhead for a larger plan: a perception-shaped small-model framework ("druid") where the model reads a rendered view of the active graph and emits tiny validated gestures, with scratch-space containment and human promotion. Design oneShot's logging and constrained-output shapes so they'd serve that future without rework — but build only what's specified above.