feat(tools): add minio ingestion into memory namespaces
This commit is contained in:
@@ -28,6 +28,7 @@ export { createGdocsTools } from './gdocs.js';
|
||||
export { createGdriveTools } from './gdrive.js';
|
||||
export { createGtasksTools } from './gtasks.js';
|
||||
export { createMinioShareTool } from './minio-share.js';
|
||||
export { createMinioIngestTool } from './minio-ingest.js';
|
||||
export { screenCaptureTool, cameraCaptureTool } from './capture.js';
|
||||
|
||||
import type { Tool } from '../types.js';
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createMinioIngestTool, minioIngestInternals } from './minio-ingest.js';
|
||||
import type { BackupConfig } from '../../config/schema.js';
|
||||
import type { MemoryStore } from '../../memory/store.js';
|
||||
|
||||
function makeBackupConfig(overrides?: Partial<BackupConfig>): BackupConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
schedule: undefined,
|
||||
interval: '24h',
|
||||
run_on_start: false,
|
||||
notify: undefined,
|
||||
failure_threshold: 1,
|
||||
notify_recovery: true,
|
||||
local_dir: '~/.local/share/flynn/backups',
|
||||
include_vectors: true,
|
||||
minio: {
|
||||
enabled: true,
|
||||
endpoint: 'localhost:9000',
|
||||
access_key: 'minio-admin',
|
||||
secret_key: 'minio-secret',
|
||||
bucket: 'flynn-knowledge',
|
||||
prefix: 'flynn',
|
||||
secure: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('minio ingest internals', () => {
|
||||
it('accepts known text-like extensions', () => {
|
||||
expect(minioIngestInternals.isLikelyTextObject('notes/today.md')).toBe(true);
|
||||
expect(minioIngestInternals.isLikelyTextObject('logs/daemon.log')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects likely binary extensions', () => {
|
||||
expect(minioIngestInternals.isLikelyTextObject('manual.pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMinioIngestTool', () => {
|
||||
it('ingests object and writes to memory', async () => {
|
||||
const write = vi.fn();
|
||||
const store = { write } as unknown as MemoryStore;
|
||||
const execRunner = vi.fn(async () => ({
|
||||
stdout: '# Runbook\n\nRestart service before deploy.\n',
|
||||
stderr: '',
|
||||
}));
|
||||
|
||||
const tool = createMinioIngestTool(makeBackupConfig(), store, {
|
||||
execRunner,
|
||||
now: () => new Date('2026-02-16T15:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await tool.execute({
|
||||
object_key: 'knowledge/runbook.md',
|
||||
namespace: 'global/runbooks',
|
||||
mode: 'append',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Ingested MinIO object');
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'global/runbooks',
|
||||
expect.stringContaining('source: minio://flynn-knowledge/knowledge/runbook.md'),
|
||||
'append',
|
||||
);
|
||||
expect(execRunner).toHaveBeenCalledWith(
|
||||
'mc',
|
||||
['cat', 'flynningest/flynn-knowledge/knowledge/runbook.md'],
|
||||
expect.objectContaining({ env: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects likely binary object unless force=true', async () => {
|
||||
const write = vi.fn();
|
||||
const store = { write } as unknown as MemoryStore;
|
||||
const execRunner = vi.fn();
|
||||
const tool = createMinioIngestTool(makeBackupConfig(), store, { execRunner });
|
||||
|
||||
const result = await tool.execute({ object_key: 'knowledge/diagram.pdf' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unsupported object type');
|
||||
expect(execRunner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows non-text extension when force=true', async () => {
|
||||
const write = vi.fn();
|
||||
const store = { write } as unknown as MemoryStore;
|
||||
const execRunner = vi.fn(async () => ({
|
||||
stdout: 'PDF text extracted upstream',
|
||||
stderr: '',
|
||||
}));
|
||||
const tool = createMinioIngestTool(makeBackupConfig(), store, { execRunner });
|
||||
|
||||
const result = await tool.execute({
|
||||
object_key: 'knowledge/diagram.pdf',
|
||||
force: true,
|
||||
mode: 'replace',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
'global/knowledge',
|
||||
expect.stringContaining('PDF text extracted upstream'),
|
||||
'replace',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an error when minio is disabled', async () => {
|
||||
const write = vi.fn();
|
||||
const store = { write } as unknown as MemoryStore;
|
||||
const tool = createMinioIngestTool(makeBackupConfig({
|
||||
minio: {
|
||||
enabled: false,
|
||||
endpoint: undefined,
|
||||
access_key: undefined,
|
||||
secret_key: undefined,
|
||||
bucket: undefined,
|
||||
prefix: 'flynn',
|
||||
secure: true,
|
||||
},
|
||||
}), store);
|
||||
|
||||
const result = await tool.execute({ object_key: 'notes/today.md' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('backup.minio.enabled=true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { extname } from 'node:path';
|
||||
import type { BackupConfig } from '../../config/schema.js';
|
||||
import type { MemoryStore } from '../../memory/store.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import { backupInternals } from '../../backup/index.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ExecRunner = (
|
||||
file: string,
|
||||
args: string[],
|
||||
options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.txt',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.csv',
|
||||
'.tsv',
|
||||
'.json',
|
||||
'.jsonl',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.log',
|
||||
'.xml',
|
||||
'.html',
|
||||
'.htm',
|
||||
]);
|
||||
|
||||
export interface MinioIngestDeps {
|
||||
execRunner?: ExecRunner;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
function isLikelyText(content: string): boolean {
|
||||
return !content.includes('\u0000');
|
||||
}
|
||||
|
||||
function isLikelyTextObject(objectKey: string): boolean {
|
||||
const ext = extname(objectKey).toLowerCase();
|
||||
if (!ext) {return true;}
|
||||
return TEXT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export const minioIngestInternals = {
|
||||
isLikelyText,
|
||||
isLikelyTextObject,
|
||||
};
|
||||
|
||||
interface MinioIngestArgs {
|
||||
object_key: string;
|
||||
bucket?: string;
|
||||
namespace?: string;
|
||||
mode?: 'append' | 'replace';
|
||||
max_chars?: number;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export function createMinioIngestTool(config: BackupConfig, store: MemoryStore, deps?: MinioIngestDeps): Tool {
|
||||
return {
|
||||
name: 'minio.ingest',
|
||||
description: 'Read a text-like object from MinIO and ingest it into memory namespace for later retrieval/search.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
object_key: {
|
||||
type: 'string',
|
||||
description: 'Object key in MinIO bucket (for example: "knowledge/runbook.md")',
|
||||
},
|
||||
bucket: {
|
||||
type: 'string',
|
||||
description: 'Optional bucket override. Defaults to backup.minio.bucket.',
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
description: 'Memory namespace to write to. Default: "global/knowledge".',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['append', 'replace'],
|
||||
description: 'Write mode for memory namespace. Default: "append".',
|
||||
},
|
||||
max_chars: {
|
||||
type: 'number',
|
||||
description: 'Maximum characters to ingest. Default: 20000.',
|
||||
},
|
||||
force: {
|
||||
type: 'boolean',
|
||||
description: 'Ingest even if file extension/content look non-text.',
|
||||
},
|
||||
},
|
||||
required: ['object_key'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
const args = rawArgs as MinioIngestArgs;
|
||||
const minio = config.minio;
|
||||
const objectKey = args.object_key?.trim();
|
||||
const namespace = args.namespace ?? 'global/knowledge';
|
||||
const mode = args.mode ?? 'append';
|
||||
const maxChars = Math.max(1, Math.floor(args.max_chars ?? 20_000));
|
||||
const force = args.force ?? false;
|
||||
const bucket = args.bucket ?? minio.bucket;
|
||||
|
||||
if (!objectKey) {
|
||||
return { success: false, output: '', error: 'object_key is required' };
|
||||
}
|
||||
if (!minio.enabled) {
|
||||
return { success: false, output: '', error: 'MinIO ingestion requires backup.minio.enabled=true' };
|
||||
}
|
||||
if (!minio.endpoint || !minio.access_key || !minio.secret_key || !bucket) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Missing MinIO credentials in backup.minio (endpoint/access_key/secret_key/bucket)',
|
||||
};
|
||||
}
|
||||
if (!force && !isLikelyTextObject(objectKey)) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: `Unsupported object type for ingestion: ${objectKey}. Use force=true if you know it is text.`,
|
||||
};
|
||||
}
|
||||
|
||||
const alias = 'flynningest';
|
||||
const host = backupInternals.buildMinioHost({
|
||||
endpoint: minio.endpoint,
|
||||
accessKey: minio.access_key,
|
||||
secretKey: minio.secret_key,
|
||||
secure: minio.secure,
|
||||
});
|
||||
const env = { ...process.env, [`MC_HOST_${alias}`]: host };
|
||||
const runner = deps?.execRunner ?? (async (file: string, cmdArgs: string[], options?: { env?: NodeJS.ProcessEnv; maxBuffer?: number }) => {
|
||||
return execFileAsync(file, cmdArgs, options);
|
||||
});
|
||||
const remotePath = `${alias}/${bucket}/${objectKey}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await runner('mc', ['cat', remotePath], { env, maxBuffer: 20 * 1024 * 1024 });
|
||||
const text = typeof stdout === 'string' ? stdout : stdout.toString('utf-8');
|
||||
|
||||
if (!force && !isLikelyText(text)) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: `Object appears binary and cannot be ingested safely: ${objectKey}. Use force=true to override.`,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: `Object is empty: minio://${bucket}/${objectKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const clipped = trimmed.length > maxChars
|
||||
? `${trimmed.slice(0, maxChars)}\n\n[truncated to ${maxChars} chars]`
|
||||
: trimmed;
|
||||
const importedAt = (deps?.now ? deps.now() : new Date()).toISOString();
|
||||
const payload = `## MinIO Import\nsource: minio://${bucket}/${objectKey}\nimported_at: ${importedAt}\n\n${clipped}`;
|
||||
store.write(namespace, payload, mode);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: `Ingested MinIO object into memory.\nSource: minio://${bucket}/${objectKey}\nNamespace: ${namespace}\nMode: ${mode}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js';
|
||||
export type { ToolExecutorConfig } from './executor.js';
|
||||
export { ToolPolicy } from './policy.js';
|
||||
export type { ToolPolicyContext } from './policy.js';
|
||||
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool } from './builtin/index.js';
|
||||
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool } from './builtin/index.js';
|
||||
export type { WebSearchConfig } from './builtin/web-search.js';
|
||||
export type { ProcessManagerConfig } from './builtin/process/index.js';
|
||||
export type { BrowserManagerConfig } from './builtin/browser/index.js';
|
||||
|
||||
@@ -17,6 +17,8 @@ const ALL_TOOL_NAMES = [
|
||||
'memory.read',
|
||||
'memory.write',
|
||||
'memory.search',
|
||||
'minio.share',
|
||||
'minio.ingest',
|
||||
'process.start',
|
||||
'process.status',
|
||||
'process.output',
|
||||
@@ -480,6 +482,19 @@ describe('ToolPolicy', () => {
|
||||
expect(names).toContain('file.read'); // from minimal
|
||||
});
|
||||
|
||||
it('expands group:minio', () => {
|
||||
const policy = new ToolPolicy(defaultConfig({
|
||||
profile: 'minimal',
|
||||
allow: ['group:minio'],
|
||||
}));
|
||||
const result = policy.filterTools(ALL_TOOLS);
|
||||
const names = result.map(t => t.name);
|
||||
expect(names).toContain('minio.share');
|
||||
expect(names).toContain('minio.ingest');
|
||||
expect(names).toContain('file.read');
|
||||
expect(names).not.toContain('shell.exec');
|
||||
});
|
||||
|
||||
it('unknown group name passes through as literal', () => {
|
||||
const policy = new ToolPolicy(defaultConfig({
|
||||
profile: 'minimal',
|
||||
|
||||
+3
-1
@@ -40,6 +40,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
||||
'cron.create',
|
||||
'cron.delete',
|
||||
'minio.share',
|
||||
'minio.ingest',
|
||||
]),
|
||||
coding: new Set([
|
||||
'file.read',
|
||||
@@ -69,6 +70,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
||||
'cron.create',
|
||||
'cron.delete',
|
||||
'minio.share',
|
||||
'minio.ingest',
|
||||
'file.write',
|
||||
'file.edit',
|
||||
'file.patch',
|
||||
@@ -104,7 +106,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
'group:gdrive': ['drive.list', 'drive.search', 'drive.read'],
|
||||
'group:gtasks': ['tasks.lists', 'tasks.list'],
|
||||
'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'],
|
||||
'group:minio': ['minio.share'],
|
||||
'group:minio': ['minio.share', 'minio.ingest'],
|
||||
};
|
||||
|
||||
/** Expand group references in a list of tool names/patterns. */
|
||||
|
||||
Reference in New Issue
Block a user