2026-06-03 21:54:30 +08:00
'use strict' ;
const fs = require ( 'fs' );
const os = require ( 'os' );
const path = require ( 'path' );
const { buildControlPaneActions } = require ( './actions' );
const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1' ;
2026-06-04 21:45:13 +08:00
const DEFAULT_STATE_STORE_RELATIVE_PATH = path . join ( '.claude' , 'ecc' , 'state.db' );
2026-06-03 21:54:30 +08:00
function homeDir ( env = process . env ) {
return env . HOME || env . USERPROFILE || os . homedir () || '.' ;
}
function defaultDbPath ( env = process . env ) {
return path . join ( homeDir ( env ), '.claude' , 'ecc2.db' );
}
2026-06-04 21:45:13 +08:00
function defaultStateDbPath ( env = process . env ) {
return path . join ( homeDir ( env ), DEFAULT_STATE_STORE_RELATIVE_PATH );
}
2026-06-03 21:54:30 +08:00
function defaultConfigPaths ( cwd = process . cwd (), env = process . env ) {
const home = homeDir ( env );
2026-06-18 16:59:30 -04:00
const paths = [ path . join ( home , 'Library' , 'Application Support' , 'ecc2' , 'config.toml' ), path . join ( home , '.config' , 'ecc2' , 'config.toml' ), path . join ( home , '.claude' , 'ecc2.toml' )];
2026-06-03 21:54:30 +08:00
let current = path . resolve ( cwd );
while ( current && current !== path . dirname ( current )) {
paths . push ( path . join ( current , '.claude' , 'ecc2.toml' ));
paths . push ( path . join ( current , 'ecc2.toml' ));
current = path . dirname ( current );
}
return Array . from ( new Set ( paths ));
}
function isPlainObject ( value ) {
return Boolean ( value ) && typeof value === 'object' && ! Array . isArray ( value );
}
function deepMerge ( base , override ) {
const merged = { ... base };
for ( const [ key , value ] of Object . entries ( override || {})) {
if ( isPlainObject ( value ) && isPlainObject ( merged [ key ])) {
merged [ key ] = deepMerge ( merged [ key ], value );
} else {
merged [ key ] = value ;
}
}
return merged ;
}
function toCamelCase ( value ) {
return String ( value ). replace ( /_([a-z])/g , ( _ , char ) => char . toUpperCase ());
}
function normalizeObjectKeys ( value ) {
if ( Array . isArray ( value )) return value . map ( normalizeObjectKeys );
if ( ! isPlainObject ( value )) return value ;
2026-06-18 16:59:30 -04:00
return Object . fromEntries ( Object . entries ( value ). map (([ key , item ]) => [ toCamelCase ( key ), normalizeObjectKeys ( item )]));
2026-06-03 21:54:30 +08:00
}
function normalizeMemoryConnectors ( connectors = {}) {
return Object . fromEntries (
Object . entries ( connectors || {})
. sort (([ left ], [ right ]) => left . localeCompare ( right ))
. map (([ name , connector ]) => [ name , normalizeObjectKeys ( connector )])
);
}
function normalizeConfig ( rawConfig = {}, options = {}) {
2026-06-18 16:59:30 -04:00
const { memory_connectors : snakeMemoryConnectors , memoryConnectors , state_db_path : snakeStateDbPath , stateDbPath : camelStateDbPath , ... rest } = rawConfig ;
2026-06-03 21:54:30 +08:00
const normalized = normalizeObjectKeys ( rest );
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized . memoryConnectors ;
return {
dbPath : options . dbPath || normalized . dbPath || defaultDbPath ( options . env ),
2026-06-18 16:59:30 -04:00
stateDbPath : options . stateDbPath || camelStateDbPath || snakeStateDbPath || normalized . stateDbPath || defaultStateDbPath ( options . env ),
memoryConnectors : normalizeMemoryConnectors ( connectorConfig )
2026-06-03 21:54:30 +08:00
};
}
function readTomlConfig ( configPath ) {
2026-09-06 20:09:27 +02:00
// @iarna/toml is required lazily so commands that never resolve a config
// file (e.g. `--help`, or a first run before any ecc2.toml exists) don't
// need it on the require path.
const toml = require ( '@iarna/toml' );
2026-06-03 21:54:30 +08:00
const raw = fs . readFileSync ( configPath , 'utf8' );
return toml . parse ( raw );
}
function resolveControlPaneConfig ( options = {}) {
const env = options . env || process . env ;
const cwd = options . cwd || process . cwd ();
2026-06-18 16:59:30 -04:00
const configPaths = options . configPath ? [ path . resolve ( options . configPath )] : defaultConfigPaths ( cwd , env );
2026-06-03 21:54:30 +08:00
let merged = {};
for ( const configPath of configPaths ) {
if ( fs . existsSync ( configPath )) {
merged = deepMerge ( merged , readTomlConfig ( configPath ));
}
}
return {
... normalizeConfig ( merged , {
env ,
dbPath : options . dbPath || env . ECC2_DB_PATH || null ,
2026-06-18 16:59:30 -04:00
stateDbPath : options . stateDbPath || env . ECC_STATE_DB_PATH || null
2026-06-03 21:54:30 +08:00
}),
2026-06-18 16:59:30 -04:00
configPaths : configPaths . filter ( configPath => fs . existsSync ( configPath ))
2026-06-03 21:54:30 +08:00
};
}
async function openSqlDatabase ( dbPath ) {
if ( ! dbPath || ! fs . existsSync ( dbPath )) return null ;
2026-09-06 20:09:27 +02:00
// sql.js is required lazily so commands that never open an existing
// ecc2.db (e.g. `--help`, or a first run before any db exists) don't need
// it on the require path.
const initSqlJs = require ( 'sql.js' );
2026-06-03 21:54:30 +08:00
const SQL = await initSqlJs ();
const buffer = fs . readFileSync ( dbPath );
return new SQL . Database ( buffer );
}
function execRows ( db , sql , params = []) {
const stmt = db . prepare ( sql );
try {
stmt . bind ( params );
const rows = [];
while ( stmt . step ()) rows . push ( stmt . getAsObject ());
return rows ;
} finally {
stmt . free ();
}
}
function tableExists ( db , tableName ) {
2026-06-18 16:59:30 -04:00
const rows = execRows ( db , "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" , [ tableName ]);
2026-06-03 21:54:30 +08:00
return rows . length > 0 ;
}
function parseJson ( value , fallback ) {
if ( typeof value !== 'string' || value . trim () === '' ) return fallback ;
try {
return JSON . parse ( value );
} catch {
return fallback ;
}
}
function toNumber ( value , fallback = 0 ) {
const parsed = Number ( value );
return Number . isFinite ( parsed ) ? parsed : fallback ;
}
function normalizeSession ( row , unreadMessages ) {
const id = String ( row . id || '' );
return {
id ,
task : String ( row . task || '' ),
project : String ( row . project || '' ),
taskGroup : String ( row . task_group || '' ),
agentType : String ( row . agent_type || '' ),
harness : String ( row . harness || 'unknown' ),
detectedHarnesses : parseJson ( row . detected_harnesses_json , []),
workingDir : String ( row . working_dir || '.' ),
state : String ( row . state || 'pending' ),
pid : row . pid === null || row . pid === undefined ? null : toNumber ( row . pid ),
worktree : row . worktree_path
? {
path : String ( row . worktree_path ),
branch : row . worktree_branch ? String ( row . worktree_branch ) : null ,
2026-06-18 16:59:30 -04:00
base : row . worktree_base ? String ( row . worktree_base ) : null
2026-06-03 21:54:30 +08:00
}
: null ,
metrics : {
inputTokens : toNumber ( row . input_tokens ),
outputTokens : toNumber ( row . output_tokens ),
tokensUsed : toNumber ( row . tokens_used ),
toolCalls : toNumber ( row . tool_calls ),
filesChanged : toNumber ( row . files_changed ),
durationSecs : toNumber ( row . duration_secs ),
2026-06-18 16:59:30 -04:00
costUsd : toNumber ( row . cost_usd )
2026-06-03 21:54:30 +08:00
},
unreadMessages : unreadMessages . get ( id ) || 0 ,
createdAt : String ( row . created_at || '' ),
updatedAt : String ( row . updated_at || '' ),
2026-06-18 16:59:30 -04:00
lastHeartbeatAt : String ( row . last_heartbeat_at || '' )
2026-06-03 21:54:30 +08:00
};
}
function readUnreadMessageCounts ( db ) {
if ( ! tableExists ( db , 'messages' )) return new Map ();
2026-06-18 16:59:30 -04:00
return new Map ( execRows ( db , 'SELECT to_session, COUNT(*) AS unread_count FROM messages WHERE read = 0 GROUP BY to_session' ). map ( row => [ String ( row . to_session ), toNumber ( row . unread_count )]));
2026-06-03 21:54:30 +08:00
}
function readSessions ( db ) {
if ( ! tableExists ( db , 'sessions' )) return [];
const unreadMessages = readUnreadMessageCounts ( db );
return execRows (
db ,
`SELECT *
FROM sessions
ORDER BY updated_at DESC, created_at DESC, id ASC
LIMIT 100`
). map ( row => normalizeSession ( row , unreadMessages ));
}
function summarizeSessions ( sessions ) {
const summary = {
totalSessions : sessions . length ,
runningSessions : 0 ,
pendingSessions : 0 ,
idleSessions : 0 ,
failedSessions : 0 ,
stoppedSessions : 0 ,
completedSessions : 0 ,
unreadMessages : 0 ,
activeWorktrees : 0 ,
totalTokens : 0 ,
2026-06-18 16:59:30 -04:00
totalCostUsd : 0
2026-06-03 21:54:30 +08:00
};
for ( const session of sessions ) {
if ( session . state === 'running' ) summary . runningSessions += 1 ;
if ( session . state === 'pending' ) summary . pendingSessions += 1 ;
if ( session . state === 'idle' ) summary . idleSessions += 1 ;
if ( session . state === 'failed' ) summary . failedSessions += 1 ;
if ( session . state === 'stopped' ) summary . stoppedSessions += 1 ;
if ( session . state === 'completed' ) summary . completedSessions += 1 ;
if ( session . worktree ) summary . activeWorktrees += 1 ;
summary . unreadMessages += session . unreadMessages ;
summary . totalTokens += session . metrics . tokensUsed ;
summary . totalCostUsd += session . metrics . costUsd ;
}
summary . totalCostUsd = Number ( summary . totalCostUsd . toFixed ( 6 ));
return summary ;
}
function readEntities ( db ) {
if ( ! tableExists ( db , 'context_graph_entities' )) return [];
return execRows (
db ,
`SELECT *
FROM context_graph_entities
ORDER BY updated_at DESC, id DESC
LIMIT 500`
). map ( row => ({
id : toNumber ( row . id ),
sessionId : row . session_id ? String ( row . session_id ) : null ,
entityType : String ( row . entity_type || '' ),
name : String ( row . name || '' ),
path : row . path ? String ( row . path ) : null ,
summary : String ( row . summary || '' ),
metadata : parseJson ( row . metadata_json , {}),
createdAt : String ( row . created_at || '' ),
2026-06-18 16:59:30 -04:00
updatedAt : String ( row . updated_at || '' )
2026-06-03 21:54:30 +08:00
}));
}
function readObservations ( db ) {
if ( ! tableExists ( db , 'context_graph_observations' )) return [];
return execRows (
db ,
`SELECT *
FROM context_graph_observations
ORDER BY created_at DESC, id DESC
LIMIT 1000`
). map ( row => ({
id : toNumber ( row . id ),
sessionId : row . session_id ? String ( row . session_id ) : null ,
entityId : toNumber ( row . entity_id ),
observationType : String ( row . observation_type || '' ),
priority : toNumber ( row . priority , 1 ),
pinned : toNumber ( row . pinned ) === 1 ,
summary : String ( row . summary || '' ),
details : parseJson ( row . details_json , {}),
2026-06-18 16:59:30 -04:00
createdAt : String ( row . created_at || '' )
2026-06-03 21:54:30 +08:00
}));
}
function readRelationCounts ( db ) {
if ( ! tableExists ( db , 'context_graph_relations' )) return new Map ();
const rows = execRows (
db ,
`SELECT entity_id, SUM(relation_count) AS relation_count
FROM (
SELECT from_entity_id AS entity_id, COUNT(*) AS relation_count
FROM context_graph_relations
GROUP BY from_entity_id
UNION ALL
SELECT to_entity_id AS entity_id, COUNT(*) AS relation_count
FROM context_graph_relations
GROUP BY to_entity_id
)
GROUP BY entity_id`
);
return new Map ( rows . map ( row => [ toNumber ( row . entity_id ), toNumber ( row . relation_count )]));
}
function tokenize ( value ) {
return String ( value || '' )
. toLowerCase ()
. split ( /[^a-z0-9_.-]+/g )
. map ( token => token . trim ())
. filter ( token => token . length >= 2 );
}
function scoreEntity ( entity , observations , relationCount , queryTerms ) {
const observationText = observations . map ( observation => observation . summary ). join ( ' ' );
const metadataText = Object . entries ( entity . metadata || {})
. map (([ key , value ]) => ` ${ key } ${ value } ` )
. join ( ' ' );
const haystacks = [
{ text : entity . name , weight : 12 },
{ text : entity . entityType , weight : 5 },
{ text : entity . path || '' , weight : 6 },
{ text : entity . summary , weight : 8 },
{ text : metadataText , weight : 5 },
2026-06-18 16:59:30 -04:00
{ text : observationText , weight : 10 }
2026-06-03 21:54:30 +08:00
]. map ( item => ({ ... item , text : item . text . toLowerCase () }));
const matchedTerms = [];
let score = 0 ;
for ( const term of queryTerms ) {
let matched = false ;
for ( const haystack of haystacks ) {
if ( haystack . text . includes ( term )) {
score += haystack . weight ;
matched = true ;
}
}
if ( matched ) matchedTerms . push ( term );
}
2026-06-18 16:59:30 -04:00
const maxPriority = observations . reduce (( highest , observation ) => Math . max ( highest , observation . priority ), 0 );
2026-06-03 21:54:30 +08:00
const hasPinnedObservation = observations . some ( observation => observation . pinned );
score += Math . min ( relationCount , 8 );
score += maxPriority * 3 ;
if ( hasPinnedObservation ) score += 8 ;
return {
score ,
matchedTerms ,
observationCount : observations . length ,
relationCount ,
maxObservationPriority : maxPriority ,
2026-06-18 16:59:30 -04:00
hasPinnedObservation
2026-06-03 21:54:30 +08:00
};
}
function recallKnowledgeEntries ({ entities , observations , relationCounts , query , limit = 12 }) {
const queryTerms = Array . from ( new Set ( tokenize ( query )));
const observationsByEntity = new Map ();
for ( const observation of observations ) {
const bucket = observationsByEntity . get ( observation . entityId ) || [];
bucket . push ( observation );
observationsByEntity . set ( observation . entityId , bucket );
}
return entities
. map ( entity => {
const entityObservations = observationsByEntity . get ( entity . id ) || [];
2026-06-18 16:59:30 -04:00
const score =
queryTerms . length > 0
? scoreEntity ( entity , entityObservations , relationCounts . get ( entity . id ) || 0 , queryTerms )
: {
score : entityObservations . some ( observation => observation . pinned ) ? 10 : 1 ,
matchedTerms : [],
observationCount : entityObservations . length ,
relationCount : relationCounts . get ( entity . id ) || 0 ,
maxObservationPriority : entityObservations . reduce (( highest , observation ) => Math . max ( highest , observation . priority ), 0 ),
hasPinnedObservation : entityObservations . some ( observation => observation . pinned )
};
2026-06-03 21:54:30 +08:00
return {
entity ,
... score ,
2026-06-18 16:59:30 -04:00
latestObservation : entityObservations [ 0 ] || null
2026-06-03 21:54:30 +08:00
};
})
. filter ( entry => queryTerms . length === 0 || entry . matchedTerms . length > 0 )
. sort (( left , right ) => {
if ( right . score !== left . score ) return right . score - left . score ;
return String ( right . entity . updatedAt ). localeCompare ( String ( left . entity . updatedAt ));
})
. slice ( 0 , Math . max ( 1 , Math . min ( Number ( limit ) || 12 , 50 )));
}
function readConnectorCheckpointRows ( db ) {
if ( ! tableExists ( db , 'context_graph_connector_checkpoints' )) return [];
return execRows (
db ,
`SELECT connector_name, COUNT(*) AS synced_sources, MAX(updated_at) AS last_synced_at
FROM context_graph_connector_checkpoints
GROUP BY connector_name`
);
}
function connectorStatus ( config , db ) {
const checkpoints = new Map (
( db ? readConnectorCheckpointRows ( db ) : []). map ( row => [
String ( row . connector_name ),
{
syncedSources : toNumber ( row . synced_sources ),
2026-06-18 16:59:30 -04:00
lastSyncedAt : row . last_synced_at ? String ( row . last_synced_at ) : null
}
2026-06-03 21:54:30 +08:00
])
);
return Object . entries ( config . memoryConnectors || {})
. sort (([ left ], [ right ]) => left . localeCompare ( right ))
. map (([ name , connector ]) => {
const checkpoint = checkpoints . get ( name ) || { syncedSources : 0 , lastSyncedAt : null };
return {
name ,
kind : connector . kind || 'unknown' ,
path : connector . path || null ,
recurse : Boolean ( connector . recurse ),
defaultEntityType : connector . defaultEntityType || null ,
defaultObservationType : connector . defaultObservationType || null ,
includeSafeValues : Boolean ( connector . includeSafeValues ),
syncedSources : checkpoint . syncedSources ,
2026-06-18 16:59:30 -04:00
lastSyncedAt : checkpoint . lastSyncedAt
2026-06-03 21:54:30 +08:00
};
});
}
2026-06-04 21:45:13 +08:00
function normalizeWorkItemStatus ( status ) {
2026-06-18 16:59:30 -04:00
const normalized = String ( status || 'open' )
. trim ()
. toLowerCase ();
2026-06-04 21:45:13 +08:00
if ([ 'done' , 'closed' , 'resolved' , 'merged' , 'cancelled' ]. includes ( normalized )) return 'done' ;
if ([ 'blocked' , 'needs-review' , 'failed' , 'stalled' ]. includes ( normalized )) return 'blocked' ;
if ([ 'running' , 'in-progress' , 'active' , 'working' ]. includes ( normalized )) return 'running' ;
return 'ready' ;
}
2026-06-18 16:59:30 -04:00
// Heuristics for whether a work item's owner is an autonomous agent or a human.
// Agent signals win when present so the board reflects who is *actively* on a card.
const AGENT_OWNER_RE = /(agent|claude|codex|hermes|gemini|opencode|qwen|joycode|codebuddy|\bbot\b|gpt|sonnet|opus|haiku|fable)/i ;
const SESSION_ID_RE = /^(sid-|tx-|proj-|sess|session|run-|wt-)/i ;
/**
* Classify the assignment of a work item for agent+human JIT team workflows.
* Returns the assignee kind ('agent' | 'human' | 'unassigned') and the resolved
* assignee label, so the board can show who owns each card and which cards are
* waiting for a just-in-time pickup.
*/
function classifyAssignee ({ owner , sessionId , metadata = {} }) {
const explicitKind = String ( metadata . assigneeKind || metadata . ownerKind || '' )
. trim ()
. toLowerCase ();
if ( explicitKind === 'agent' || explicitKind === 'human' ) {
return { assigneeKind : explicitKind , assignee : owner || sessionId || metadata . assignee || null };
}
const ownerStr = owner ? String ( owner ) : '' ;
if ( sessionId || ( ownerStr && ( AGENT_OWNER_RE . test ( ownerStr ) || SESSION_ID_RE . test ( ownerStr )))) {
return { assigneeKind : 'agent' , assignee : ownerStr || String ( sessionId ) };
}
if ( ownerStr ) {
return { assigneeKind : 'human' , assignee : ownerStr };
}
return { assigneeKind : 'unassigned' , assignee : null };
}
2026-06-04 21:45:13 +08:00
function normalizeWorkItem ( row ) {
const parsedMetadata = parseJson ( row . metadata , {});
const metadata = isPlainObject ( parsedMetadata ) ? normalizeObjectKeys ( parsedMetadata ) : {};
const kanbanState = normalizeWorkItemStatus ( row . status );
2026-06-18 16:59:30 -04:00
const owner = row . owner ? String ( row . owner ) : null ;
const sessionId = row . session_id ? String ( row . session_id ) : null ;
const { assigneeKind , assignee } = classifyAssignee ({ owner , sessionId , metadata });
2026-06-04 21:45:13 +08:00
return {
id : String ( row . id || '' ),
source : String ( row . source || '' ),
sourceId : row . source_id ? String ( row . source_id ) : null ,
title : String ( row . title || '' ),
status : String ( row . status || 'open' ),
kanbanState ,
priority : row . priority ? String ( row . priority ) : null ,
url : row . url ? String ( row . url ) : null ,
2026-06-18 16:59:30 -04:00
owner ,
assigneeKind ,
assignee ,
2026-06-04 21:45:13 +08:00
repoRoot : row . repo_root ? String ( row . repo_root ) : null ,
2026-06-18 16:59:30 -04:00
sessionId ,
2026-06-04 21:45:13 +08:00
branch : metadata . branch || metadata . headRefName || null ,
mergeGate : metadata . mergeGate || metadata . mergeGateStatus || metadata . mergeStateStatus || null ,
blocker : metadata . blocker || null ,
acceptance : Array . isArray ( metadata . acceptance ) ? metadata . acceptance . map ( String ) : [],
metadata ,
createdAt : String ( row . created_at || '' ),
2026-06-18 16:59:30 -04:00
updatedAt : String ( row . updated_at || '' )
2026-06-04 21:45:13 +08:00
};
}
function readWorkItems ( db ) {
if ( ! tableExists ( db , 'work_items' )) return [];
return execRows (
db ,
`SELECT *
FROM work_items
ORDER BY updated_at DESC, id DESC
LIMIT 100`
). map ( normalizeWorkItem );
}
function summarizeWorkItems ( items ) {
const summary = {
totalCount : items . length ,
openCount : 0 ,
blockedCount : 0 ,
doneCount : 0 ,
kanban : {
ready : 0 ,
running : 0 ,
blocked : 0 ,
2026-06-18 16:59:30 -04:00
done : 0
2026-06-04 21:45:13 +08:00
},
2026-06-18 16:59:30 -04:00
// Agent + human JIT team-workflow view: who owns the open work, and which
// open cards are waiting for a just-in-time pickup.
assignment : {
agent : 0 ,
human : 0 ,
unassigned : 0
},
needsAssignment : [],
items
2026-06-04 21:45:13 +08:00
};
for ( const item of items ) {
const kanbanState = normalizeWorkItemStatus ( item . kanbanState || item . status );
summary . kanban [ kanbanState ] += 1 ;
2026-06-18 16:59:30 -04:00
const isOpen = kanbanState !== 'done' ;
2026-06-04 21:45:13 +08:00
if ( kanbanState === 'done' ) {
summary . doneCount += 1 ;
} else {
summary . openCount += 1 ;
}
if ( kanbanState === 'blocked' ) summary . blockedCount += 1 ;
2026-06-18 16:59:30 -04:00
// Assignment is only meaningful for open work; done cards don't need an owner.
if ( isOpen ) {
const kind = item . assigneeKind || classifyAssignee ( item ). assigneeKind ;
summary . assignment [ kind ] = ( summary . assignment [ kind ] || 0 ) + 1 ;
if ( kind === 'unassigned' ) {
summary . needsAssignment . push ({
id : item . id ,
title : item . title ,
kanbanState ,
priority : item . priority || null ,
url : item . url || null
});
}
}
2026-06-04 21:45:13 +08:00
}
2026-06-18 16:59:30 -04:00
// Surface the highest-priority unclaimed work first for JIT pickup.
const priorityRank = { critical : 0 , high : 1 , urgent : 1 , medium : 2 , normal : 2 , low : 3 };
summary . needsAssignment . sort (( a , b ) => {
const ra = priorityRank [ String ( a . priority || '' ). toLowerCase ()] ?? 2 ;
const rb = priorityRank [ String ( b . priority || '' ). toLowerCase ()] ?? 2 ;
return ra - rb ;
});
2026-06-04 21:45:13 +08:00
return summary ;
}
async function readWorkItemsSnapshot ( stateDbPath ) {
let db = null ;
try {
db = await openSqlDatabase ( stateDbPath );
if ( ! db ) return summarizeWorkItems ([]);
return summarizeWorkItems ( readWorkItems ( db ));
} catch {
return summarizeWorkItems ([]);
} finally {
if ( db ) db . close ();
}
}
2026-06-03 21:54:30 +08:00
async function buildControlPaneSnapshot ( options = {}) {
const repoRoot = path . resolve ( options . repoRoot || path . join ( __dirname , '..' , '..' , '..' ));
const config = options . config
? normalizeConfig ( options . config , {
env : options . env || process . env ,
dbPath : options . dbPath || options . config . dbPath || null ,
2026-06-18 16:59:30 -04:00
stateDbPath : options . stateDbPath || options . config . stateDbPath || null
2026-06-03 21:54:30 +08:00
})
: resolveControlPaneConfig ( options );
const dbPath = options . dbPath || config . dbPath ;
2026-06-04 21:45:13 +08:00
const stateDbPath = options . stateDbPath || config . stateDbPath ;
2026-06-03 21:54:30 +08:00
const query = String ( options . query || '' ). trim ();
const limit = Math . max ( 1 , Math . min ( Number . parseInt ( String ( options . limit || 12 ), 10 ) || 12 , 50 ));
const generatedAt = new Date (). toISOString ();
2026-06-04 21:45:13 +08:00
const workItems = await readWorkItemsSnapshot ( stateDbPath );
2026-06-03 21:54:30 +08:00
const base = {
schemaVersion : SNAPSHOT_SCHEMA_VERSION ,
generatedAt ,
repoRoot ,
dbPath ,
2026-06-04 21:45:13 +08:00
stateDbPath ,
2026-06-03 21:54:30 +08:00
database : {
2026-06-18 16:59:30 -04:00
exists : Boolean ( dbPath && fs . existsSync ( dbPath ))
2026-06-03 21:54:30 +08:00
},
2026-06-04 21:45:13 +08:00
stateDatabase : {
2026-06-18 16:59:30 -04:00
exists : Boolean ( stateDbPath && fs . existsSync ( stateDbPath ))
2026-06-04 21:45:13 +08:00
},
2026-06-03 21:54:30 +08:00
config : {
configPaths : config . configPaths || [],
2026-06-18 16:59:30 -04:00
memoryConnectorCount : Object . keys ( config . memoryConnectors || {}). length
2026-06-03 21:54:30 +08:00
},
execution : {
2026-06-18 16:59:30 -04:00
allowActions : options . allowActions !== false
2026-06-03 21:54:30 +08:00
},
summary : summarizeSessions ([]),
sessions : [],
knowledge : {
query ,
entityCount : 0 ,
observationCount : 0 ,
2026-06-18 16:59:30 -04:00
results : []
2026-06-03 21:54:30 +08:00
},
connectors : connectorStatus ( config , null ),
2026-06-04 21:45:13 +08:00
workItems ,
2026-06-18 16:59:30 -04:00
actions : buildControlPaneActions ({ repoRoot , query , limit })
2026-06-03 21:54:30 +08:00
};
const db = await openSqlDatabase ( dbPath );
if ( ! db ) {
return base ;
}
try {
const sessions = readSessions ( db );
const entities = readEntities ( db );
const observations = readObservations ( db );
const relationCounts = readRelationCounts ( db );
2026-06-20 15:46:19 -04:00
// Proximity (agent-space collision avoidance) is opt-in: it shells `git diff`
// per worktree, so we only compute it when explicitly requested to keep the
// default snapshot fast.
let proximity = null ;
if ( options . includeProximity ) {
const { buildProximitySnapshot } = require ( './proximity' );
proximity = buildProximitySnapshot ( sessions , { repoRoot , ...( options . proximityOptions || {}) });
}
2026-06-03 21:54:30 +08:00
return {
... base ,
summary : summarizeSessions ( sessions ),
sessions ,
knowledge : {
query ,
entityCount : entities . length ,
observationCount : observations . length ,
results : recallKnowledgeEntries ({
entities ,
observations ,
relationCounts ,
query ,
2026-06-18 16:59:30 -04:00
limit
})
2026-06-03 21:54:30 +08:00
},
2026-06-20 15:46:19 -04:00
connectors : connectorStatus ( config , db ),
proximity
2026-06-03 21:54:30 +08:00
};
} finally {
db . close ();
}
}
module . exports = {
SNAPSHOT_SCHEMA_VERSION ,
buildControlPaneSnapshot ,
defaultConfigPaths ,
2026-06-04 21:45:13 +08:00
defaultStateDbPath ,
2026-06-03 21:54:30 +08:00
recallKnowledgeEntries ,
2026-06-18 16:59:30 -04:00
resolveControlPaneConfig
2026-06-03 21:54:30 +08:00
};