moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,949 @@
import {
type MindDB,
type MemoryFrame,
type ScoringProfile,
IdentityLayer,
AwarenessLayer,
FrameStore,
SessionStore,
HybridSearch,
KnowledgeGraph,
ImprovementSignalStore,
createCoreLogger,
type Embedder,
TEMPORAL_GUIDANCE,
renderReferenceDateLine,
parseDateWindow,
createInProcessReranker,
type Reranker,
MIND_FACT_PREFIX,
MIND_EVENT_PREFIX,
MIND_PROFILE_PREFIX,
MIND_RAWTURN_PREFIX,
fetchRawDetailLane,
rawTurnBody,
type RawTurnHit,
} from '@waggle/core';
import { createMindTools, type ToolDefinition } from './tools.js';
import { buildSelfAwareness, type AgentCapabilities } from './self-awareness.js';
import { renderGoalAncestry } from './goal-ancestry.js';
import type { GoalAncestry } from '@waggle/shared';
import { buildAwarenessSummary, markSummarySurfaced, type AwarenessSummary } from './improvement-detector.js';
import { CognifyPipeline } from './cognify.js';
import { scanForInjection } from './injection-scanner.js';
import { runPatternWriteBack } from './pattern-write-back.js';
import {
fetchRecentFrames,
loadRecentContext as loadRecentContextImpl,
loadRecentContextFrames as loadRecentContextFramesImpl,
type ContextFrames as ContextFramesImpl,
} from './context-loader.js';
// Re-export ContextFrames for back-compat — tests + apps import this type
// from `./orchestrator` per the pre-PR-F surface.
export type ContextFrames = ContextFramesImpl;
// H-AUDIT-1 contract: turnId is a per-turn trace ID (UUID v4) generated at
// chat-route turn entry and propagated EXPLICITLY through every downstream
// stage (agent-loop → orchestrator → retrieval → prompt-assembler → cognify
// → tool-calls). No AsyncLocalStorage, no globals — propagation is
// tsc-verifiable via optional `turnId?: string` params on each entry
// function. See `turn-context.ts` for the generator + logging helpers.
import { logTurnEvent } from './turn-context.js';
import { tierForModel, type ModelTier } from './model-tier.js';
import type { AgentPersona } from './personas.js';
import {
PromptAssembler,
type AssembleOptions,
type AssembledPrompt,
type RecalledMemory,
} from './prompt-assembler.js';
const logger = createCoreLogger('orchestrator');
// Content-length constants now live in `./content-constants.ts` (single
// source of truth shared with the pattern-write-back extractor). Imports
// below pull only the ones this file still references.
import {
CONTEXT_PREVIEW_LENGTH,
RECALL_LINE_LENGTH,
RECALLED_SNIPPET_LENGTH,
} from './content-constants.js';
export interface OrchestratorConfig {
db: MindDB;
embedder: Embedder;
apiKey?: string;
model?: string;
mode?: 'local' | 'team';
version?: string;
skills?: string[];
/**
* W4.2: optional cross-encoder reranker injected for tests. When absent,
* a lazy in-process reranker (Xenova/ms-marco-MiniLM-L-6-v2, ~22MB ONNX)
* is created on first recall IF the WAGGLE_RERANKER=1 flag is set;
* creation failure soft-fails to RRF-only ordering.
*/
reranker?: Reranker;
/** AI-OS #6 — durable "why" breadcrumb injected into buildSystemPrompt. */
goalAncestry?: GoalAncestry;
}
/**
* Options for tier-adaptive recall. When omitted, `recallMemory` behaves
* byte-identically to its pre-PromptAssembler implementation.
*/
export interface RecallOptions {
/** ScoringProfile forwarded to HybridSearch. Default: 'balanced'. */
profile?: ScoringProfile;
/** Drop results whose finalScore is below this floor. Default: no filter. */
scoreFloor?: number;
/** Model tier hint — recorded for downstream consumers (PromptAssembler). */
tier?: ModelTier;
/** H-AUDIT-1: per-turn trace ID (UUID v4). Logs memory-recall stage. */
turnId?: string;
}
/**
* Workspace-specific layers — created when a workspace mind is activated.
* Separate from personal mind layers so both can be queried.
*/
interface WorkspaceLayers {
db: MindDB;
frames: FrameStore;
sessions: SessionStore;
search: HybridSearch;
knowledge: KnowledgeGraph;
cognify: CognifyPipeline;
}
export class Orchestrator {
private db: MindDB;
private embedder: Embedder;
private identity: IdentityLayer;
private awareness: AwarenessLayer;
private frames: FrameStore;
private sessions: SessionStore;
private search: HybridSearch;
private knowledge: KnowledgeGraph;
private tools: ToolDefinition[];
private model: string;
private mode: 'local' | 'team';
private version: string;
private skills: string[];
private improvementSignals: ImprovementSignalStore;
/** AI-OS #6 — durable "why" breadcrumb; null = no section rendered. */
private goalAncestry: GoalAncestry | null = null;
/** M8: deferred signal marking — collected during buildSystemPrompt, committed after model call */
private _pendingSurfacedAwareness: AwarenessSummary | null = null;
/** Workspace-specific layers (null when no workspace is active) */
private workspaceLayers: WorkspaceLayers | null = null;
/** Personal-mind cognify pipeline (#12: compaction-summary persistence). */
private cognify: CognifyPipeline;
/** W4.2: memoized reranker promise — resolves undefined on creation failure. */
private rerankerPromise: Promise<Reranker | undefined> | null = null;
/** Team sync client — set for team workspaces, null for personal */
private teamSync: import('@waggle/core').TeamSync | null = null;
/**
* Section cache — stores computed values and their inputs.
* Cached sections are recomputed only when their input changes.
*/
private _sectionCache = new Map<string, { input: string; output: string }>();
constructor(config: OrchestratorConfig) {
this.db = config.db;
this.embedder = config.embedder;
this.model = config.model ?? 'unknown';
this.mode = config.mode ?? 'local';
this.version = config.version ?? '0.0.0';
this.skills = config.skills ?? [];
this.goalAncestry = config.goalAncestry ?? null;
this.identity = new IdentityLayer(config.db);
this.awareness = new AwarenessLayer(config.db);
this.frames = new FrameStore(config.db);
this.sessions = new SessionStore(config.db);
this.search = new HybridSearch(config.db, config.embedder);
if (config.reranker) this.rerankerPromise = Promise.resolve(config.reranker);
this.knowledge = new KnowledgeGraph(config.db);
this.improvementSignals = new ImprovementSignalStore(config.db);
const cognify = new CognifyPipeline({
frames: this.frames,
sessions: this.sessions,
knowledge: this.knowledge,
search: this.search,
});
this.cognify = cognify;
this.tools = createMindTools({
db: this.db,
identity: this.identity,
awareness: this.awareness,
frames: this.frames,
sessions: this.sessions,
search: this.search,
knowledge: this.knowledge,
cognify,
// Skills 2.0 gap K: write-path contradiction detection emits a
// correction signal through this store when a new save conflicts
// with an existing frame.
improvementSignals: this.improvementSignals,
// Provide workspace accessors so tools can route to the right mind
getWorkspaceLayers: () => this.workspaceLayers,
});
}
/**
* Activate a workspace mind alongside the personal mind.
* Creates workspace-specific layers for frames, search, knowledge, cognify.
* Identity always stays in personal mind.
*/
setWorkspaceMind(workspaceDb: MindDB): void {
if (this.workspaceLayers) {
logger.info('switching workspace mind — replacing previous workspace layers');
}
const frames = new FrameStore(workspaceDb);
const sessions = new SessionStore(workspaceDb);
const search = new HybridSearch(workspaceDb, this.embedder);
const knowledge = new KnowledgeGraph(workspaceDb);
const cognify = new CognifyPipeline({
frames,
sessions,
knowledge,
search,
});
this.workspaceLayers = { db: workspaceDb, frames, sessions, search, knowledge, cognify };
}
/**
* AI-OS #6 — set/replace the goal-ancestry breadcrumb rendered by the next
* buildSystemPrompt(). Pass null to clear it. Mutable like setWorkspaceMind
* so the per-session caller can populate it once context is resolved.
*/
setGoalAncestry(ancestry: GoalAncestry | null): void {
this.goalAncestry = ancestry;
}
/**
* Clear the workspace mind (back to personal-only mode).
*/
clearWorkspaceMind(): void {
this.workspaceLayers = null;
}
/** Set the TeamSync client for push-on-write to team server. */
setTeamSync(sync: import('@waggle/core').TeamSync | null): void {
this.teamSync = sync;
}
/** Whether a workspace mind is currently active */
hasWorkspaceMind(): boolean {
return this.workspaceLayers !== null;
}
getMemoryStats(): { frameCount: number; sessionCount: number; entityCount: number } {
// Intentionally not cached: ancillary write paths (direct
// KnowledgeGraph.createEntity / FrameStore.createIFrame) would skip
// cache invalidation. Cost is 6× COUNT(*) per user turn — negligible
// below ~100k frames. If scale ever bites, fix via a write-counter
// in MindDB, not a time-based cache.
const raw = this.db.getDatabase();
const frameCount = (raw.prepare('SELECT COUNT(*) as cnt FROM memory_frames').get() as { cnt: number }).cnt;
const sessionCount = (raw.prepare('SELECT COUNT(*) as cnt FROM sessions').get() as { cnt: number }).cnt;
const entityCount = (raw.prepare('SELECT COUNT(*) as cnt FROM knowledge_entities').get() as { cnt: number }).cnt;
if (this.workspaceLayers) {
const wsRaw = this.workspaceLayers.db.getDatabase();
const wsFrames = (wsRaw.prepare('SELECT COUNT(*) as cnt FROM memory_frames').get() as { cnt: number }).cnt;
const wsSessions = (wsRaw.prepare('SELECT COUNT(*) as cnt FROM sessions').get() as { cnt: number }).cnt;
const wsEntities = (wsRaw.prepare('SELECT COUNT(*) as cnt FROM knowledge_entities').get() as { cnt: number }).cnt;
return {
frameCount: frameCount + wsFrames,
sessionCount: sessionCount + wsSessions,
entityCount: entityCount + wsEntities,
};
}
return { frameCount, sessionCount, entityCount };
}
/**
* Load recent context from memory for session preloading. Delegates to
* `loadRecentContextImpl` — see `./context-loader.ts`.
*/
loadRecentContext(limit = 5): string {
return loadRecentContextImpl(this.contextLoaderDeps(), limit);
}
/**
* Typed counterpart for `PromptAssembler`. Delegates to
* `loadRecentContextFramesImpl` — see `./context-loader.ts`. Pure data;
* injection scanning is the assembler's responsibility (it has tier
* context needed to decide drop vs sanitize).
*/
loadRecentContextFrames(limit = 10): ContextFrames {
return loadRecentContextFramesImpl(this.contextLoaderDeps(), limit);
}
/** Assemble ContextLoaderDeps from this orchestrator's current layers. */
private contextLoaderDeps() {
return {
personalDb: this.db,
workspaceDb: this.workspaceLayers?.db ?? null,
awareness: this.awareness,
};
}
/**
* Return a cached section value if the input hasn't changed.
* Avoids redundant string construction for stable sections (e.g., identity).
*/
private cachedSection(name: string, input: string, compute: () => string): string {
const cached = this._sectionCache.get(name);
if (cached && cached.input === input) return cached.output;
const output = compute();
this._sectionCache.set(name, { input, output });
return output;
}
/**
* Always recomputes — for sections that depend on runtime state.
* Structurally consistent with cachedSection for future TTL-based optimization.
*/
private uncachedSection(_name: string, compute: () => string): string {
return compute();
}
buildSystemPrompt(): string {
// ── IDENTITY (always personal, stable within a session) ──
// Cache key must hash the full identity content — updated_at alone
// has only second precision in SQLite, so rapid successive edits
// (and fresh-mind tests) collide on the timestamp.
const identitySection = this.cachedSection(
'identity',
this.identity.exists() ? JSON.stringify(this.identity.get()) : 'empty',
() => this.identity.exists() ? '# Identity\n' + this.identity.toContext() : '',
);
// ── GOAL ANCESTRY (the durable "why"; changes only when re-set) ──
const goalAncestrySection = this.cachedSection(
'goal_ancestry',
JSON.stringify(this.goalAncestry) || 'empty',
() => renderGoalAncestry(this.goalAncestry),
);
// ── SELF-AWARENESS (runtime context, changes every call) ──
const awarenessSection = this.uncachedSection('self_awareness', () => {
const awareness = buildAwarenessSummary(this.improvementSignals);
// Defer marking until commitSurfacedSignals() fires post-model-call.
if (awareness.totalActionable > 0) {
this._pendingSurfacedAwareness = awareness;
}
const caps: AgentCapabilities = {
tools: this.tools.map(t => ({ name: t.name, description: t.description })),
skills: this.skills,
model: this.model,
memoryStats: this.getMemoryStats(),
mode: this.mode,
version: this.version,
awareness: awareness.totalActionable > 0 ? awareness : undefined,
};
return buildSelfAwareness(caps);
});
// ── PRELOADED CONTEXT (per-session memory, changes every call) ──
const contextSection = this.uncachedSection('recent_context', () => {
const recentContext = this.loadRecentContext();
return recentContext
? '# Context From Your Memory\nThis was automatically loaded — you already know this:\n' + recentContext
: '';
});
const parts = [identitySection, goalAncestrySection, awarenessSection, contextSection].filter(Boolean);
return parts.join('\n\n');
}
/**
* PromptAssembler integration — produces a tier-adaptive, typed,
* scaffolded prompt via the new sixth layer.
*
* Consumers: agent-loop.ts when `isEnabled('PROMPT_ASSEMBLER')`.
* Feature-flagged, default off — callers outside that gate should keep
* using `buildSystemPrompt()` + `recallMemory()` directly.
*/
async buildAssembledPrompt(
query: string,
persona: AgentPersona | null = null,
opts: AssembleOptions = {},
): Promise<AssembledPrompt> {
const tier = tierForModel(this.model);
const corePrompt = this.buildSystemPrompt();
const context = this.loadRecentContextFrames();
let recalled: RecalledMemory;
if (opts.recalledText !== undefined) {
// W4.5 (plan bug #9-2, double-compute): the caller already ran
// recallMemory this turn — reuse its rendered multi-lane block instead
// of re-running the searches. recallMemory scans for injection itself
// (returns '' on a hit), so scanSafe is true by construction here.
recalled = {
workspace: [],
personal: [],
scanSafe: true,
renderedText: opts.recalledText,
};
} else {
// Direct search for raw frames (recallMemory returns formatted text;
// the assembler consumes MemoryFrame[] and applies its own rendering).
const personalResults = await this.search.search(query, { limit: 10, profile: 'balanced' });
const workspaceResults = this.workspaceLayers
? await this.workspaceLayers.search.search(query, { limit: 10, profile: 'balanced' })
: [];
// Brief §8: run the injection scan here; assembler trusts scanSafe and
// must not re-scan. On a poisoned hit, frames still flow through but
// scanSafe=false causes the assembler to ignore the recall section.
const joinedContent = [
...workspaceResults.map(r => r.frame.content),
...personalResults.map(r => r.frame.content),
].join('\n');
const scanSafe = joinedContent.length === 0
? true
: scanForInjection(joinedContent, 'tool_output').safe;
recalled = {
workspace: workspaceResults.map(r => r.frame),
personal: personalResults.map(r => r.frame),
scanSafe,
};
}
return new PromptAssembler().assemble(
{
corePrompt,
persona,
context,
recalled,
query,
tier,
},
opts,
);
}
/**
* M8: Commit deferred signal markings after model call succeeds.
* Call this after the LLM response is received. If the model call fails,
* skip this call — signals stay actionable for the next turn.
*/
commitSurfacedSignals(): void {
if (this._pendingSurfacedAwareness) {
markSummarySurfaced(this.improvementSignals, this._pendingSurfacedAwareness);
this._pendingSurfacedAwareness = null;
}
}
/**
* Automatic memory recall: search for memories relevant to the user's query.
* Searches BOTH personal and workspace minds when workspace is active.
* Returns formatted recall text with source attribution.
*
* `opts` is optional — when omitted, behavior is byte-identical to the
* pre-PromptAssembler implementation (profile='balanced', no score floor).
*/
/**
* W4.2/W4.5: lazy cross-encoder reranker — DEFAULT ON since the W4.5 live
* smoke (real ONNX load + 58-83ms warm recalls verified through the real
* server). Kill switch: WAGGLE_RERANKER=0. First use downloads the ~22MB
* model (cached at ~/.hive-mind/models); creation failure (offline, OOM)
* memoizes undefined: recall soft-fails to RRF-only ordering, never throws.
*/
private getReranker(): Promise<Reranker | undefined> {
if (this.rerankerPromise) return this.rerankerPromise;
if (process.env['WAGGLE_RERANKER'] === '0') {
this.rerankerPromise = Promise.resolve(undefined);
return this.rerankerPromise;
}
this.rerankerPromise = createInProcessReranker().catch((e: unknown) => {
logger.warn('reranker unavailable — falling back to RRF ordering', {
error: e instanceof Error ? e.message : String(e),
});
return undefined;
});
return this.rerankerPromise;
}
async recallMemory(
query: string,
limit = 10,
opts?: RecallOptions,
): Promise<{ text: string; count: number; recalled?: string[]; recalledFrames?: Array<{ source: string }> }> {
const profile: ScoringProfile = opts?.profile ?? 'balanced';
const scoreFloor = opts?.scoreFloor;
logTurnEvent(opts?.turnId, { stage: 'orchestrator.recallMemory.enter', queryChars: query.length, limit, profile });
try {
// Detect catch-up intent — these queries need importance-based recall, not literal text matching
const catchUpPatterns = [
/\bcatch me up\b/i, /\bwhere (?:are|were) we\b/i, /\bwhat matters\b/i,
/\bwhat did we decide\b/i, /\bwhere did we leave off\b/i, /\bwhat['']?s the status\b/i,
/\bwhat should I do\b/i, /\bwhat['']?s next\b/i, /\bsummariz/i, /\bbrief me\b/i,
/\bget me up to speed\b/i, /\bwhat['']?s going on\b/i, /\bremind me\b/i,
];
const isCatchUp = catchUpPatterns.some(p => p.test(query));
let personalResults;
let workspaceResults;
// W4.1b/W4.3b: parsed explicit-period window — drives since/until in the
// normal branch AND the "Events during X" render section below.
let dateWindow: ReturnType<typeof parseDateWindow> = null;
// W4.6: hoisted so the RAWDETAIL lane below can reuse the same instance.
// Stays undefined on the catch-up branch — raw-detail escalation targets
// specific-detail queries, not status summaries.
let reranker: Reranker | undefined;
if (isCatchUp && this.workspaceLayers) {
// For catch-up queries: fetch important frames by importance + recency, not semantic search.
// Dedup MUST be by frame id, not content prefix — two frames sharing a 100-char prefix
// ("Decision: use Postgres" vs "Decision: use Postgres (revised)") otherwise collapse.
const wsRaw = this.workspaceLayers.db.getDatabase();
type CatchUpRow = { id: number; content: string; frame_type: string; importance: string; source: string; created_at: string };
const importantFrames = wsRaw.prepare(
`SELECT id, content, frame_type, importance, source, created_at
FROM memory_frames
WHERE importance IN ('critical', 'important')
OR content LIKE 'Decision%'
OR content LIKE '%decided%'
ORDER BY
CASE importance WHEN 'critical' THEN 0 WHEN 'important' THEN 1 ELSE 2 END,
id DESC
LIMIT ?`
).all(limit) as CatchUpRow[];
// Also get the most recent frames for recency context (shared helper)
const recentFrames = fetchRecentFrames(
this.workspaceLayers!.db, Math.min(limit, 3), { excludeTemporary: true },
) as CatchUpRow[];
// Combine and deduplicate by frame id
const seen = new Set<number>();
const combined: CatchUpRow[] = [];
for (const f of [...importantFrames, ...recentFrames]) {
if (!seen.has(f.id)) {
seen.add(f.id);
combined.push(f);
}
}
workspaceResults = combined.slice(0, limit).map(f => ({
score: 1,
frame: { content: f.content, importance: f.importance, source: f.source, created_at: f.created_at },
}));
personalResults = await this.search.search(query, { limit: 2, profile });
} else {
// W4.1b (#3) — deterministic date-window lane: when the query names an
// explicit period ("in May 2026", "on 13 October 2025", "in 2024"),
// restrict recall to frames created in that window via the substrate's
// since/until filter. Graceful degradation: a window that matches
// nothing falls back to unwindowed search below — the lane must never
// LOSE recall, only sharpen it.
dateWindow = parseDateWindow(query);
const windowOpts = dateWindow
? { since: dateWindow.since, until: dateWindow.until }
: {};
// W4.2: cross-encoder reranker (soft-fails to undefined → RRF order).
reranker = await this.getReranker();
// Normal semantic search for specific queries
personalResults = await this.search.search(query, { limit, profile, reranker, ...windowOpts });
workspaceResults = this.workspaceLayers
? await this.workspaceLayers.search.search(query, { limit, profile, reranker, ...windowOpts })
: [];
if (dateWindow && personalResults.length === 0 && workspaceResults.length === 0) {
logTurnEvent(opts?.turnId, { stage: 'orchestrator.recallMemory.dateWindowEmpty', label: dateWindow.label });
personalResults = await this.search.search(query, { limit, profile, reranker });
workspaceResults = this.workspaceLayers
? await this.workspaceLayers.search.search(query, { limit, profile, reranker })
: [];
}
// W4.1 (#2) — unconditional importance lane (benchmark fetchImportantFrames
// K=5): critical/important frames reach recall on EVERY query, not only on
// catch-up regex matches. Active mind only (workspace when set, else
// personal); rendered BEFORE semantic hits (benchmark order); deduped by
// frame id so a frame surfaced by both lanes renders once.
const IMPORTANCE_LANE_K = 5;
const laneDb = this.workspaceLayers?.db ?? this.db;
type LaneRow = { id: number; content: string; frame_type: string; importance: string; source: string; created_at: string };
const laneRows = laneDb.getDatabase().prepare(
// PR3.5: source added (additive column — no WHERE/ORDER/LIMIT change,
// so the rendered recall text stays byte-identical) so the importance
// lane's frames carry provenance for the auto_recall step pill.
`SELECT id, content, frame_type, importance, source, created_at
FROM memory_frames
WHERE importance IN ('critical', 'important')
ORDER BY
CASE importance WHEN 'critical' THEN 0 WHEN 'important' THEN 1 ELSE 2 END,
id DESC
LIMIT ?`
).all(IMPORTANCE_LANE_K) as LaneRow[];
if (laneRows.length > 0) {
const laneIds = new Set(laneRows.map(f => f.id));
const laneResults = laneRows.map(f => ({ score: 1, frame: f }));
const notInLane = (r: { frame: { id?: number } }): boolean =>
r.frame.id === undefined || !laneIds.has(r.frame.id);
if (this.workspaceLayers) {
workspaceResults = [...laneResults, ...workspaceResults.filter(notInLane)];
} else {
personalResults = [...laneResults, ...personalResults.filter(notInLane)];
}
}
}
// R2 sign-gate: self-incapacity frames are persisted at 'temporary'
// importance so they don't re-enter the prompt as authoritative recall.
// HybridSearch treats importance as a SCORE, not an EXCLUSION — apply
// the SQL path's `!= 'temporary' AND != 'deprecated'` filter here too.
const isAuthoritativeForRecall = (r: { frame: { importance?: string } }): boolean => {
const imp = r.frame.importance ?? 'normal';
return imp !== 'temporary' && imp !== 'deprecated';
};
personalResults = personalResults.filter(isAuthoritativeForRecall);
workspaceResults = workspaceResults.filter(isAuthoritativeForRecall);
// Apply optional score floor (PromptAssembler opt-in; byte-identical when absent).
if (scoreFloor !== undefined) {
const passes = (r: { finalScore?: number; score?: number }): boolean =>
(r.finalScore ?? r.score ?? 1) >= scoreFloor;
personalResults = personalResults.filter(passes);
workspaceResults = workspaceResults.filter(passes);
}
// ── W4.3b: extraction-lane fetches (benchmark lanes #5/#6/#8) ──────
// Prefix-tagged frames written by extract-memory-lanes (cron/harvest).
// Active mind only; caps keep the rendered block token-bounded:
// facts most-recent 60, events most-recent 40 (chronological render —
// the wholesale chronological block is load-bearing; cap, don't rank).
// PR3.5 (review M-4): `source` added to all three lane SELECTs (column-only,
// no WHERE/ORDER/LIMIT change → rendered recall text byte-identical) so the
// auto_recall provenance breakdown reflects EVERY recalled frame, not just
// the semantic + importance lanes (otherwise these dominant lanes drop to
// 'unknown' and the pill undercounts).
type LaneFrameRow = { id: number; content: string; importance: string; source: string; created_at: string };
const laneMindDb = (this.workspaceLayers?.db ?? this.db).getDatabase();
const profileFrames = laneMindDb.prepare(
`SELECT id, content, importance, source, created_at FROM memory_frames
WHERE content LIKE '${MIND_PROFILE_PREFIX} %' ORDER BY id ASC`
).all() as LaneFrameRow[];
const factFrames = (laneMindDb.prepare(
`SELECT id, content, importance, source, created_at FROM memory_frames
WHERE content LIKE '${MIND_FACT_PREFIX}%' ORDER BY id DESC LIMIT 60`
).all() as LaneFrameRow[]).reverse();
const eventFramesAll = laneMindDb.prepare(
`SELECT id, content, importance, source, created_at FROM memory_frames
WHERE content LIKE '${MIND_EVENT_PREFIX}%' ORDER BY created_at ASC, id ASC`
).all() as LaneFrameRow[];
const eventFrames = eventFramesAll.slice(-40);
// Dedup: lane frames never double-render via the search lanes; profile
// frames are excluded from snippets UNCONDITIONALLY (benchmark rule).
// W4.6: raw-turn frames likewise render ONLY via their own verbatim
// excerpts section — as snippets they'd carry their [mind-rawturn …]
// header noise and crowd the semantic top-K the summary frames serve.
const laneFrameIds = new Set<number>([
...profileFrames.map(f => f.id),
...factFrames.map(f => f.id),
...eventFramesAll.map(f => f.id),
]);
const notLaneFrame = (r: { frame: { id?: number; content: string } }): boolean =>
!(r.frame.id !== undefined && laneFrameIds.has(r.frame.id)) &&
!r.frame.content.startsWith(MIND_PROFILE_PREFIX) &&
!r.frame.content.startsWith(MIND_RAWTURN_PREFIX);
personalResults = personalResults.filter(notLaneFrame);
workspaceResults = workspaceResults.filter(notLaneFrame);
/** Body of a prefix-tagged lane frame (everything after the header line). */
const laneBody = (content: string): string => {
const nl = content.indexOf('\n');
return nl >= 0 ? content.slice(nl + 1).trim() : content;
};
const allLines: string[] = [];
// Render order is the benchmark's: profiles → facts → events →
// windowed events → snippets (workspace/personal sections below).
if (profileFrames.length > 0) {
allLines.push('## Profiles');
for (const f of profileFrames) {
const m = f.content.match(/^\[mind-profile ([^\]]+)\]/);
const name = m ? m[1] : 'Person';
allLines.push(`- ${name}: ${laneBody(f.content).slice(0, 1200)}`);
}
}
if (factFrames.length > 0) {
allLines.push('## Memory Facts');
for (const f of factFrames) {
const date = f.created_at?.slice(0, 10);
const datePrefix = date ? `[${date}] ` : '';
allLines.push(`- ${datePrefix}${laneBody(f.content).slice(0, RECALL_LINE_LENGTH)}`);
}
}
if (eventFrames.length > 0) {
allLines.push('## Events (chronological)');
for (const f of eventFrames) {
// body already carries its [YYYY-MM-DD] resolved-event-date prefix
allLines.push(`- ${laneBody(f.content).slice(0, RECALL_LINE_LENGTH)}`);
}
}
// W4.3b: explicit-period queries surface the events INSIDE the window as
// a dedicated section (uncapped — windows are small) so the model binds
// to the right event instead of a similar one from another month.
if (dateWindow) {
const windowEvents = eventFramesAll.filter(f => {
const d = String(f.created_at ?? '').slice(0, 10);
return d >= dateWindow!.since && d <= dateWindow!.until;
});
if (windowEvents.length > 0) {
allLines.push(`## Events during ${dateWindow.label}`);
for (const f of windowEvents) {
allLines.push(`- ${laneBody(f.content).slice(0, RECALL_LINE_LENGTH)}`);
}
}
}
if (workspaceResults.length > 0) {
allLines.push('## Workspace Memory');
for (const r of workspaceResults) {
const date = r.frame.created_at?.slice(0, 10) ?? 'unknown';
allLines.push(`- [${date}, ${r.frame.importance}] ${r.frame.content.slice(0, RECALL_LINE_LENGTH)}`);
}
}
if (personalResults.length > 0) {
allLines.push('## Personal Memory');
if (this.workspaceLayers) {
allLines.push('_(Cross-workspace personal knowledge — not specific to this workspace)_');
}
for (const r of personalResults) {
const date = r.frame.created_at?.slice(0, 10) ?? 'unknown';
allLines.push(`- [${date}, ${r.frame.importance}] ${r.frame.content.slice(0, RECALL_LINE_LENGTH)}`);
}
}
// ── W4.6: RAWDETAIL escalation lane (benchmark lane #10) ───────────
// Verbatim turn excerpts rendered LAST: escalation evidence for
// fine-grained detail the distilled lanes only carry generically
// (W3.4 ablation: +2.40 z=1.95 — the single-hop driver). Requires the
// cross-encoder (P5 anti-goal: no relevance-only injection without
// the CE floor) — catch-up queries and reranker-less recalls skip it.
// Kill switch: WAGGLE_RAWDETAIL=0.
let rawDetailHits: RawTurnHit[] = [];
if (reranker && process.env['WAGGLE_RAWDETAIL'] !== '0') {
try {
const excludeIds = new Set<number>(laneFrameIds);
for (const r of [...workspaceResults, ...personalResults]) {
const id = (r.frame as { id?: number }).id;
if (id !== undefined) excludeIds.add(id);
}
rawDetailHits = await fetchRawDetailLane(laneMindDb, query, reranker, {
window: dateWindow ? { since: dateWindow.since, until: dateWindow.until } : null,
excludeIds,
});
} catch (err) {
// Lane failure never blocks recall — the other 6 lanes stand.
logger.warn('raw-detail lane failed — skipping', {
error: err instanceof Error ? err.message : String(err),
});
}
}
if (rawDetailHits.length > 0) {
allLines.push('## Raw dialogue excerpts (verbatim)');
for (const h of rawDetailHits) {
const date = h.created_at ? `[${String(h.created_at).slice(0, 10)}] ` : '';
// Speaker is parenthesized, NOT colon-suffixed: "assistant:" /
// "system:" are chat-template-smuggling patterns the read-side
// injection scanner rightly flags — the render format must never
// collide with them.
allLines.push(`- ${date}(${h.speaker}) ${rawTurnBody(h.content).slice(0, RECALL_LINE_LENGTH)}`);
}
}
const laneCount = profileFrames.length + factFrames.length + eventFrames.length + rawDetailHits.length;
const totalCount = personalResults.length + workspaceResults.length + laneCount;
if (totalCount === 0) {
logTurnEvent(opts?.turnId, { stage: 'orchestrator.recallMemory.exit', totalCount: 0, blocked: false });
return { text: '', count: 0, recalled: [], recalledFrames: [] };
}
// Collect content snippets for UI display (B5 fix)
const recalled: string[] = [];
// PR3.5: per-frame provenance for the auto_recall step pill. Source is
// read defensively — full MemoryFrames (semantic results) and importance-
// lane rows carry it; frames from lanes that don't SELECT source fall back
// to 'unknown' (the FE excludes 'unknown' from the breakdown — never a
// fabricated source).
const recalledFrames: Array<{ source: string }> = [];
for (const r of [...workspaceResults, ...personalResults]) {
recalled.push(r.frame.content.slice(0, RECALLED_SNIPPET_LENGTH));
recalledFrames.push({ source: (r.frame as { source?: string }).source ?? 'unknown' });
}
for (const f of [...profileFrames, ...factFrames, ...eventFrames, ...rawDetailHits]) {
recalled.push(f.content.slice(0, RECALLED_SNIPPET_LENGTH));
recalledFrames.push({ source: (f as { source?: string }).source ?? 'unknown' });
}
// Scan recalled memory for injection — a poisoned harvest frame
// (e.g. ChatGPT export with embedded "ignore previous instructions")
// must never silently flow into model context.
const joinedLines = allLines.join('\n');
const scan = scanForInjection(joinedLines, 'tool_output');
if (!scan.safe) {
logger.warn('recalled-memory injection detected — blocking recall', {
score: scan.score,
flags: scan.flags,
count: totalCount,
});
logTurnEvent(opts?.turnId, { stage: 'orchestrator.recallMemory.exit', totalCount, blocked: true, injectionScore: scan.score });
return { text: '', count: 0, recalled: [], recalledFrames: [] };
}
// W4.1 (#1): anchor = max created_at across all rendered frames.
const anchorLine = renderReferenceDateLine([
...[...workspaceResults, ...personalResults].map(r => r.frame.created_at),
...[...profileFrames, ...factFrames, ...eventFrames].map(f => f.created_at),
...rawDetailHits.map(h => h.created_at),
]);
const text = '# Recalled Memories\n'
+ "These are facts saved in this WORKSPACE'S memory, retrieved for the user's current message. "
+ 'They may come from earlier sessions, other sessions, or imported sources — NOT necessarily from this conversation.\n'
+ 'IMPORTANT — ground your response in them, but attribute provenance HONESTLY:\n'
+ '- Attribute saved / earlier-session memory EXPLICITLY as memory: "your saved memory shows…", "in an earlier session you noted…", "from your workspace notes…". Never imply an ongoing relationship — do NOT say "welcome back", "you\'re back in context", "as we\'ve been discussing", or "from our last session", even when the recalled memory is real and cross-session. Reserve "you just said" / "as you mentioned" strictly for things said earlier in THIS same conversation.\n'
+ '- On the user\'s first message, do NOT claim continuity ("welcome back", "as we discussed", "you\'re back in context") — you have no prior turn with them yet.\n'
+ '- State ONLY what the memories below actually say. Do NOT add specifics — runway figures, headcounts, dollar amounts, dates, percentages, entity COUNTS, or competitor names — unless they appear verbatim in the memories. A detail that feels plausible but is not written below is CONFABULATION: ask instead of asserting. (Observed failures to avoid: stating "4 months runway" or "227 entities tracked" when neither appears in the memories.)\n'
+ '- Do NOT ignore relevant memories. Do NOT present memory content as your own reasoning — attribute it.\n'
// W4.1 (#1): temporal guidance rides with the recalled block (NOT the
// global system prompt) + a reference-date anchor so the model has a
// concrete "now" to resolve relative time against. Both derive from
// static text / frame dates — no injection surface beyond joinedLines
// (already scanned above).
+ TEMPORAL_GUIDANCE + '\n\n'
+ (anchorLine ? anchorLine + '\n' : '')
+ joinedLines;
logTurnEvent(opts?.turnId, {
stage: 'orchestrator.recallMemory.exit',
totalCount,
workspaceHits: workspaceResults.length,
personalHits: personalResults.length,
textChars: text.length,
});
return { text, count: totalCount, recalled, recalledFrames };
} catch (err) {
// Surface failures visibly — silent empty results train the model
// to confabulate "I don't remember" instead of recalling real memory.
logger.error('recallMemory failed', err);
return {
text: '[Memory recall temporarily unavailable. Proceed without prior context.]',
count: 0,
recalled: [],
recalledFrames: [],
};
}
}
/**
* Post-response heuristic write-back. Delegates to `runPatternWriteBack` —
* the regex pattern set + extractor logic live in `./pattern-write-back.ts`.
* Routes preferences/corrections/style to personal mind; decisions and
* work-output to workspace (or personal when no workspace is active).
*/
async autoSaveFromExchange(
userMsg: string,
assistantMsg: string,
opts?: { traceId?: string },
): Promise<string[]> {
return runPatternWriteBack(
{
personal: { db: this.db, frames: this.frames, sessions: this.sessions },
workspace: this.workspaceLayers
? {
frames: this.workspaceLayers.frames,
sessions: this.workspaceLayers.sessions,
cognify: this.workspaceLayers.cognify,
}
: null,
teamSync: this.teamSync,
},
userMsg,
assistantMsg,
opts,
);
}
/**
* #12: persist the context-compaction summary as a memory frame — the
* dual-use of the one compaction LLM call (already re-injected into live
* context by the compressor; this makes it durable). Zero extra LLM cost:
* cognify's entity extraction is regex. One frame per session, updated in
* place on later compaction passes (each pass is a superset — the previous
* summary feeds the summarizer). Routes to the workspace mind when active,
* else personal (mirrors save_memory). Returns the frame id, or null when
* nothing was persisted.
*/
async persistCompactionSummary(
summary: string,
sessionKey: string,
priorFrameId?: number | null,
): Promise<number | null> {
if (!summary.trim()) return null;
// Deliberately NO sign-gate here: the summary is a multi-section
// COMPACTION_PROMPT aggregate, and a single boilerplate "you'll need to
// run X" line inside it would downgrade the whole session gist to
// 'temporary' (recall-invisible) — silently no-op'ing the feature for
// exactly the long sessions it targets. Provenance is source='system'.
const importance = 'normal';
const marker = `[Session summary — ${sessionKey}]`;
const content = `${marker}\n\n${summary}`;
const frames = this.workspaceLayers?.frames ?? this.frames;
const cognify = this.workspaceLayers?.cognify ?? this.cognify;
// Update in place ONLY when the prior frame is verifiably this session's
// summary. The caller's id map is keyed by session while this method
// routes by active mind — after a workspace switch the same rowid can
// point at an UNRELATED frame in the new mind, and a blind update would
// destructively overwrite user memory.
if (priorFrameId != null) {
const existing = frames.getById(priorFrameId);
if (existing?.content.startsWith(marker)) {
const updated = frames.update(priorFrameId, content, importance);
if (updated) return priorFrameId;
}
}
const result = await cognify.cognify(content, importance, undefined, undefined, 'system');
return result.frameId;
}
getTools(): ToolDefinition[] {
return this.tools;
}
async executeTool(name: string, args: Record<string, unknown>): Promise<string> {
const tool = this.tools.find(t => t.name === name);
if (!tool) throw new Error(`Unknown tool: ${name}`);
return tool.execute(args);
}
getIdentity(): IdentityLayer { return this.identity; }
getAwareness(): AwarenessLayer { return this.awareness; }
getFrames(): FrameStore { return this.frames; }
getSessions(): SessionStore { return this.sessions; }
getSearch(): HybridSearch { return this.search; }
getKnowledge(): KnowledgeGraph { return this.knowledge; }
getImprovementSignals(): ImprovementSignalStore { return this.improvementSignals; }
}