diff --git a/src/builtin-agents.ts b/src/builtin-agents.ts index 03dd1a7..f3935bd 100644 --- a/src/builtin-agents.ts +++ b/src/builtin-agents.ts @@ -55,6 +55,7 @@ function getBuiltinAgentsDir(): string { export async function deployBuiltinAgents( teamConfig: TeamaiConfig, localConfig?: LocalConfig, + options?: { skipRecall?: boolean }, ): Promise { const builtinDir = getBuiltinAgentsDir(); if (!await pathExists(builtinDir)) { @@ -69,7 +70,9 @@ export async function deployBuiltinAgents( return 0; } - const agentFiles = entries.filter((f) => f.endsWith('.md') && !f.startsWith('.')); + const agentFiles = entries + .filter((f) => f.endsWith('.md') && !f.startsWith('.')) + .filter((f) => !(options?.skipRecall && f === 'teamai-recall.md')); if (agentFiles.length === 0) return 0; const baseDir = localConfig ? resolveBaseDir(localConfig) : (process.env.HOME ?? ''); diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index e9cbdee..22cbe13 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -43,13 +43,17 @@ export const EXCLUDED_RULE_NAMES = new Set([ * * @returns Number of tool directories that received built-in rules. */ -export async function deployBuiltinRules(teamConfig: TeamaiConfig, localConfig?: LocalConfig): Promise { +export async function deployBuiltinRules( + teamConfig: TeamaiConfig, + localConfig?: LocalConfig, + options?: { skipRecall?: boolean }, +): Promise { const baseDir = localConfig ? resolveBaseDir(localConfig) : (process.env.HOME ?? ''); let deployed = 0; const builtinRules: Array<{ name: string; content: string }> = [ { name: 'teamai-recall', content: TEAMAI_RECALL_RULE_CONTENT }, - ]; + ].filter(r => !(options?.skipRecall && r.name === 'teamai-recall')); for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { if (!toolPath.rules) continue; diff --git a/src/index.ts b/src/index.ts index 32d9fb8..4d2c4e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -541,7 +541,7 @@ program // ─── Recall commands ───────────────────────────────────── -program +const recallCmd = program .command('recall [query...]') .description('Search team learnings knowledge base') .option('--depth ', 'Recall depth: route (entry-points only) | context (module-level, default) | lookup (full graph traversal)', 'context') @@ -552,6 +552,33 @@ program await recall(query, { ...globalOpts, depth: cmdOpts.depth }); }); +recallCmd + .command('disable') + .description('Disable automatic knowledge-base recall') + .action(async () => { + const globalOpts = program.opts() as GlobalOptions; + const { recallDisable } = await import('./recall-toggle.js'); + await recallDisable(globalOpts); + }); + +recallCmd + .command('enable') + .description('Enable automatic knowledge-base recall') + .action(async () => { + const globalOpts = program.opts() as GlobalOptions; + const { recallEnable } = await import('./recall-toggle.js'); + await recallEnable(globalOpts); + }); + +recallCmd + .command('status') + .description('Show recall feature status') + .action(async () => { + const globalOpts = program.opts() as GlobalOptions; + const { recallStatus } = await import('./recall-toggle.js'); + await recallStatus(globalOpts); + }); + program .command('todowrite-hint', { hidden: true }) diff --git a/src/pull.ts b/src/pull.ts index 92b0479..b400081 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -22,6 +22,7 @@ import { CultureFrontmatterSchema, resolveBaseDir, getTeamaiHome, + isRecallEnabled, } from './types.js'; import type { CultureFrontmatter } from './types.js'; import { loadRolesManifest, resolveRoleResourceNamespaces, type ResourceNamespaces } from './roles.js'; @@ -316,8 +317,9 @@ async function pullForScope( if (!options.dryRun) { const cfg = await loadTeamConfig(localConfig.repo.localPath); if (cfg) { - try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(cfg, localConfig); } catch {} - try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(cfg, localConfig); } catch {} + const skipRecall = !isRecallEnabled(localConfig, cfg); + try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(cfg, localConfig, { skipRecall }); } catch {} + try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(cfg, localConfig, { skipRecall }); } catch {} try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(cfg, localConfig, { reportingOnly }); } catch {} } } @@ -710,7 +712,7 @@ async function pullForScope( // configured. Tools without subagent support (cursor / codex / openclaw / // workbuddy) are skipped — for them the recall flow runs purely via the // TodoWrite hint hook and the manual `teamai recall` command. - if (!options.dryRun) { + if (!options.dryRun && isRecallEnabled(localConfig, freshConfig)) { try { const baseDir = resolveBaseDir(localConfig); const recallBlock = compileRecallRulesBlock(); @@ -758,7 +760,8 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinRules } = await import('./builtin-rules.js'); - const deployed = await deployBuiltinRules(freshConfig, localConfig); + const skipRecall = !isRecallEnabled(localConfig, freshConfig); + const deployed = await deployBuiltinRules(freshConfig, localConfig, { skipRecall }); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed built-in rules to ${deployed} tool(s)`); } @@ -771,7 +774,8 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); - const deployed = await deployBuiltinAgents(freshConfig, localConfig); + const skipRecall = !isRecallEnabled(localConfig, freshConfig); + const deployed = await deployBuiltinAgents(freshConfig, localConfig, { skipRecall }); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed built-in agents to ${deployed} location(s)`); } diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts new file mode 100644 index 0000000..b8a8226 --- /dev/null +++ b/src/recall-toggle.ts @@ -0,0 +1,126 @@ +import path from 'node:path'; +import { autoDetectInit, saveLocalConfigForScope } from './config.js'; +import { log } from './utils/logger.js'; +import { readFileSafe, writeFile, remove, pathExists } from './utils/fs.js'; +import { ResourceHandler } from './resources/base.js'; +import { + resolveBaseDir, + isRecallEnabled, + TEAMAI_RECALL_RULES_START, + TEAMAI_RECALL_RULES_END, + type GlobalOptions, + type TeamaiConfig, + type LocalConfig, +} from './types.js'; + +async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const baseDir = resolveBaseDir(localConfig); + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + // Remove recall rule file + if (toolPath.rules) { + const ruleFile = path.join(baseDir, toolPath.rules, 'teamai-recall.md'); + if (await pathExists(ruleFile)) { + await remove(ruleFile); + log.debug(`Removed recall rule from ${tool}`); + } + } + + // Remove recall agent file + if (toolPath.agents) { + const agentFile = path.join(baseDir, toolPath.agents, 'teamai-recall.md'); + if (await pathExists(agentFile)) { + await remove(agentFile); + log.debug(`Removed recall agent from ${tool}`); + } + } + + // Remove recall block from CLAUDE.md + if (toolPath.claudemd) { + const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const content = await readFileSafe(claudeMdPath); + if (content && content.includes(TEAMAI_RECALL_RULES_START)) { + const startIdx = content.indexOf(TEAMAI_RECALL_RULES_START); + const endIdx = content.indexOf(TEAMAI_RECALL_RULES_END); + if (startIdx !== -1 && endIdx !== -1) { + const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); + const after = content.substring(endIdx + TEAMAI_RECALL_RULES_END.length).replace(/^\n+/, '\n'); + const cleaned = (before + after).trim(); + if (cleaned.length === 0) { + await remove(claudeMdPath); + } else { + await writeFile(claudeMdPath, cleaned + '\n'); + } + log.debug(`Removed recall rules block from ${tool} CLAUDE.md`); + } + } + } + } +} + +async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { deployBuiltinRules } = await import('./builtin-rules.js'); + const { deployBuiltinAgents } = await import('./builtin-agents.js'); + + await deployBuiltinRules(teamConfig, localConfig, { skipRecall: false }); + await deployBuiltinAgents(teamConfig, localConfig, { skipRecall: false }); + + // Inject recall rules block into CLAUDE.md for Tier-1 tools + const { injectClaudeMdSection } = await import('./utils/claudemd.js'); + const { compileRecallRulesBlock } = await import('./pull.js'); + const baseDir = resolveBaseDir(localConfig); + const recallBlock = compileRecallRulesBlock(); + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + if (!toolPath.claudemd || !toolPath.agents) continue; + if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) continue; + + const claudeMdPath = path.join(baseDir, toolPath.claudemd); + try { + await injectClaudeMdSection( + claudeMdPath, + TEAMAI_RECALL_RULES_START, + TEAMAI_RECALL_RULES_END, + recallBlock, + ); + } catch { + // best-effort + } + } +} + +export async function recallDisable(_opts: GlobalOptions): Promise { + const { localConfig, teamConfig } = await autoDetectInit(); + + const updated = { ...localConfig, recallEnabled: false }; + await saveLocalConfigForScope(updated, localConfig.scope, localConfig.projectRoot); + + await removeRecallArtifacts(teamConfig, localConfig); + log.success('Recall disabled. AI tools will no longer auto-search the knowledge base.'); +} + +export async function recallEnable(_opts: GlobalOptions): Promise { + const { localConfig, teamConfig } = await autoDetectInit(); + + const updated = { ...localConfig, recallEnabled: true }; + await saveLocalConfigForScope(updated, localConfig.scope, localConfig.projectRoot); + + await deployRecallArtifacts(teamConfig, localConfig); + log.success('Recall enabled. AI tools will auto-search the knowledge base before tasks.'); +} + +export async function recallStatus(_opts: GlobalOptions): Promise { + const { localConfig, teamConfig } = await autoDetectInit(); + + const effective = isRecallEnabled(localConfig, teamConfig); + const teamSetting = teamConfig.sharing?.recall?.enabled ?? false; + const userOverride = localConfig.recallEnabled; + + console.log(`Recall: ${effective ? 'enabled' : 'disabled'}`); + console.log(` Team config (sharing.recall.enabled): ${teamSetting}`); + if (userOverride !== undefined) { + console.log(` User override (recallEnabled): ${userOverride}`); + } else { + console.log(` User override: not set (using team default)`); + } +} diff --git a/src/types.ts b/src/types.ts index 854bff9..5a3d643 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,9 @@ export const SharingConfigSchema = z.object({ /** Restrict team hook commands to scripts under ~/.teamai/team-scripts/. */ requireTeamScripts: z.boolean().default(false), }).optional(), + recall: z.object({ + enabled: z.boolean().default(false), + }).optional(), }); /** Defaulted view of the optional `sharing.hooks` config. */ @@ -55,6 +58,22 @@ export function getHooksSharing(config: { sharing?: { hooks?: { autoApply?: bool }; } +/** Defaulted view of the optional `sharing.recall` config. */ +export function getRecallSharing(config: { sharing?: { recall?: { enabled?: boolean } } }): { + enabled: boolean; +} { + return { enabled: config.sharing?.recall?.enabled ?? false }; +} + +/** Resolve whether recall is enabled: user override > team config > default (false). */ +export function isRecallEnabled( + localConfig: { recallEnabled?: boolean }, + teamConfig: { sharing?: { recall?: { enabled?: boolean } } }, +): boolean { + if (localConfig.recallEnabled !== undefined) return localConfig.recallEnabled; + return getRecallSharing(teamConfig).enabled; +} + // ─── Source config (cross-team subscription) ───────── // // Data flow: @@ -166,6 +185,8 @@ export const LocalConfigSchema = z.object({ projectRoot: z.string().optional(), /** Tags the user has subscribed to. If empty/undefined, pull all resources. */ subscribedTags: z.array(z.string()).optional(), + /** User-level override for recall feature. When set, takes precedence over team config. */ + recallEnabled: z.boolean().optional(), }); export type LocalConfig = z.infer;