2026-01-12 16:46:42 -05:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-08 11:08:49 -07:00
|
|
|
import * as fs from 'node:fs/promises';
|
|
|
|
|
import * as path from 'node:path';
|
2026-01-12 16:46:42 -05:00
|
|
|
import type { Config } from '../config/config.js';
|
2026-04-08 11:08:49 -07:00
|
|
|
import { Storage } from '../config/storage.js';
|
2026-02-09 18:01:59 -08:00
|
|
|
import { flattenMemory } from '../config/memory.js';
|
2026-04-08 11:08:49 -07:00
|
|
|
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
|
|
|
|
|
import { readExtractionState } from '../services/memoryService.js';
|
2026-01-12 16:46:42 -05:00
|
|
|
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
|
|
|
|
import type { MessageActionReturn, ToolActionReturn } from './types.js';
|
|
|
|
|
|
|
|
|
|
export function showMemory(config: Config): MessageActionReturn {
|
2026-02-09 18:01:59 -08:00
|
|
|
const memoryContent = flattenMemory(config.getUserMemory());
|
2026-01-12 16:46:42 -05:00
|
|
|
const fileCount = config.getGeminiMdFileCount() || 0;
|
|
|
|
|
let content: string;
|
|
|
|
|
|
|
|
|
|
if (memoryContent.length > 0) {
|
|
|
|
|
content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`;
|
|
|
|
|
} else {
|
|
|
|
|
content = 'Memory is currently empty.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'message',
|
|
|
|
|
messageType: 'info',
|
|
|
|
|
content,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addMemory(
|
|
|
|
|
args?: string,
|
|
|
|
|
): MessageActionReturn | ToolActionReturn {
|
|
|
|
|
if (!args || args.trim() === '') {
|
|
|
|
|
return {
|
|
|
|
|
type: 'message',
|
|
|
|
|
messageType: 'error',
|
|
|
|
|
content: 'Usage: /memory add <text to remember>',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
type: 'tool',
|
|
|
|
|
toolName: 'save_memory',
|
|
|
|
|
toolArgs: { fact: args.trim() },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function refreshMemory(
|
|
|
|
|
config: Config,
|
|
|
|
|
): Promise<MessageActionReturn> {
|
|
|
|
|
let memoryContent = '';
|
|
|
|
|
let fileCount = 0;
|
|
|
|
|
|
|
|
|
|
if (config.isJitContextEnabled()) {
|
2026-04-02 09:22:04 -07:00
|
|
|
await config.getMemoryContextManager()?.refresh();
|
2026-02-09 18:01:59 -08:00
|
|
|
memoryContent = flattenMemory(config.getUserMemory());
|
2026-01-12 16:46:42 -05:00
|
|
|
fileCount = config.getGeminiMdFileCount();
|
|
|
|
|
} else {
|
|
|
|
|
const result = await refreshServerHierarchicalMemory(config);
|
2026-02-09 18:01:59 -08:00
|
|
|
memoryContent = flattenMemory(result.memoryContent);
|
2026-01-12 16:46:42 -05:00
|
|
|
fileCount = result.fileCount;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 18:45:24 -05:00
|
|
|
config.updateSystemInstructionIfInitialized();
|
2026-01-12 16:46:42 -05:00
|
|
|
let content: string;
|
|
|
|
|
|
|
|
|
|
if (memoryContent.length > 0) {
|
2026-03-07 14:56:11 -08:00
|
|
|
content = `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s)`;
|
2026-01-12 16:46:42 -05:00
|
|
|
} else {
|
2026-03-07 14:56:11 -08:00
|
|
|
content = 'Memory reloaded successfully. No memory content found';
|
2026-01-12 16:46:42 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'message',
|
|
|
|
|
messageType: 'info',
|
|
|
|
|
content,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listMemoryFiles(config: Config): MessageActionReturn {
|
|
|
|
|
const filePaths = config.getGeminiMdFilePaths() || [];
|
|
|
|
|
const fileCount = filePaths.length;
|
|
|
|
|
let content: string;
|
|
|
|
|
|
|
|
|
|
if (fileCount > 0) {
|
|
|
|
|
content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join(
|
|
|
|
|
'\n',
|
|
|
|
|
)}`;
|
|
|
|
|
} else {
|
|
|
|
|
content = 'No GEMINI.md files in use.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'message',
|
|
|
|
|
messageType: 'info',
|
|
|
|
|
content,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-08 11:08:49 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Represents a skill found in the extraction inbox.
|
|
|
|
|
*/
|
|
|
|
|
export interface InboxSkill {
|
|
|
|
|
/** Directory name in the inbox. */
|
|
|
|
|
dirName: string;
|
|
|
|
|
/** Skill name from SKILL.md frontmatter. */
|
|
|
|
|
name: string;
|
|
|
|
|
/** Skill description from SKILL.md frontmatter. */
|
|
|
|
|
description: string;
|
|
|
|
|
/** When the skill was extracted (ISO string), if known. */
|
|
|
|
|
extractedAt?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scans the skill extraction inbox and returns structured data
|
|
|
|
|
* for each extracted skill.
|
|
|
|
|
*/
|
|
|
|
|
export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
|
|
|
|
|
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
|
|
|
|
|
|
|
|
|
let entries: Array<import('node:fs').Dirent>;
|
|
|
|
|
try {
|
|
|
|
|
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dirs = entries.filter((e) => e.isDirectory());
|
|
|
|
|
if (dirs.length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load extraction state to get dates
|
|
|
|
|
const memoryDir = config.storage.getProjectMemoryTempDir();
|
|
|
|
|
const statePath = path.join(memoryDir, '.extraction-state.json');
|
|
|
|
|
const state = await readExtractionState(statePath);
|
|
|
|
|
|
|
|
|
|
// Build a map: skillDirName → extractedAt
|
|
|
|
|
const skillDateMap = new Map<string, string>();
|
|
|
|
|
for (const run of state.runs) {
|
|
|
|
|
for (const skillName of run.skillsCreated) {
|
|
|
|
|
skillDateMap.set(skillName, run.runAt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skills: InboxSkill[] = [];
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
const skillPath = path.join(skillsDir, dir.name, 'SKILL.md');
|
|
|
|
|
const skillDef = await loadSkillFromFile(skillPath);
|
|
|
|
|
if (!skillDef) continue;
|
|
|
|
|
|
|
|
|
|
skills.push({
|
|
|
|
|
dirName: dir.name,
|
|
|
|
|
name: skillDef.name,
|
|
|
|
|
description: skillDef.description,
|
|
|
|
|
extractedAt: skillDateMap.get(dir.name),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return skills;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type InboxSkillDestination = 'global' | 'project';
|
|
|
|
|
|
|
|
|
|
function isValidInboxSkillDirName(dirName: string): boolean {
|
|
|
|
|
return (
|
|
|
|
|
dirName.length > 0 &&
|
|
|
|
|
dirName !== '.' &&
|
|
|
|
|
dirName !== '..' &&
|
|
|
|
|
!dirName.includes('/') &&
|
|
|
|
|
!dirName.includes('\\')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSkillNameForConflictCheck(
|
|
|
|
|
skillDir: string,
|
|
|
|
|
fallbackName: string,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const skill = await loadSkillFromFile(path.join(skillDir, 'SKILL.md'));
|
|
|
|
|
return skill?.name ?? fallbackName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Copies an inbox skill to the target skills directory.
|
|
|
|
|
*/
|
|
|
|
|
export async function moveInboxSkill(
|
|
|
|
|
config: Config,
|
|
|
|
|
dirName: string,
|
|
|
|
|
destination: InboxSkillDestination,
|
|
|
|
|
): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
if (!isValidInboxSkillDirName(dirName)) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: 'Invalid skill name.',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
|
|
|
|
const sourcePath = path.join(skillsDir, dirName);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(sourcePath);
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: `Skill "${dirName}" not found in inbox.`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetBase =
|
|
|
|
|
destination === 'global'
|
|
|
|
|
? Storage.getUserSkillsDir()
|
|
|
|
|
: config.storage.getProjectSkillsDir();
|
|
|
|
|
const targetPath = path.join(targetBase, dirName);
|
|
|
|
|
const skillName = await getSkillNameForConflictCheck(sourcePath, dirName);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(targetPath);
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
// Target doesn't exist — good
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingTargetSkills = await loadSkillsFromDir(targetBase);
|
|
|
|
|
if (existingTargetSkills.some((skill) => skill.name === skillName)) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.mkdir(targetBase, { recursive: true });
|
|
|
|
|
await fs.cp(sourcePath, targetPath, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Remove from inbox after successful copy
|
|
|
|
|
await fs.rm(sourcePath, { recursive: true, force: true });
|
|
|
|
|
|
|
|
|
|
const label =
|
|
|
|
|
destination === 'global' ? '~/.gemini/skills' : '.gemini/skills';
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
message: `Moved "${dirName}" to ${label}.`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Removes a skill from the extraction inbox.
|
|
|
|
|
*/
|
|
|
|
|
export async function dismissInboxSkill(
|
|
|
|
|
config: Config,
|
|
|
|
|
dirName: string,
|
|
|
|
|
): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
if (!isValidInboxSkillDirName(dirName)) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: 'Invalid skill name.',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
|
|
|
|
const sourcePath = path.join(skillsDir, dirName);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(sourcePath);
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: `Skill "${dirName}" not found in inbox.`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.rm(sourcePath, { recursive: true, force: true });
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
message: `Dismissed "${dirName}" from inbox.`,
|
|
|
|
|
};
|
|
|
|
|
}
|