@hanhnd/agent-kit 1.0.33 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli/commands/cli-digest-pending.test.js +19 -10
  2. package/dist/cli/commands/memory.js +44 -4
  3. package/dist/cli.js +307 -129
  4. package/dist/core/config/index.d.ts +3 -0
  5. package/dist/core/config/index.js +20 -0
  6. package/dist/core/handoffs/index.d.ts +8 -7
  7. package/dist/core/handoffs/index.js +12 -4
  8. package/dist/core/handoffs/index.test.d.ts +1 -0
  9. package/dist/core/handoffs/index.test.js +88 -0
  10. package/dist/entrypoints/server.js +1 -3
  11. package/dist/mcp/agent.d.ts +1 -1
  12. package/dist/mcp/agent.js +3 -3
  13. package/dist/mcp/core.d.ts +7 -0
  14. package/dist/mcp/core.js +39 -25
  15. package/dist/mcp/core.test.d.ts +1 -0
  16. package/dist/mcp/core.test.js +98 -0
  17. package/dist/mcp/memory.d.ts +2 -1
  18. package/dist/mcp/memory.js +45 -18
  19. package/dist/server.js +302 -178
  20. package/dist/services/digest/constants.d.ts +3 -3
  21. package/dist/services/digest/constants.js +3 -3
  22. package/dist/services/digest/digest-background.test.js +19 -8
  23. package/dist/services/digest/digest-init.test.js +13 -2
  24. package/dist/services/digest/digest-pending.test.js +13 -2
  25. package/dist/services/digest/digest-processor.test.js +66 -0
  26. package/dist/services/digest/files.js +3 -3
  27. package/dist/services/digest/model-registry.js +2 -2
  28. package/dist/services/digest/processor.js +43 -13
  29. package/dist/services/digest/providers/llama-local.js +17 -28
  30. package/dist/services/digest/types.d.ts +3 -1
  31. package/dist/services/digest/types.js +1 -1
  32. package/dist/services/memory/chunker.d.ts +5 -2
  33. package/dist/services/memory/chunker.js +3 -1
  34. package/dist/services/memory/chunker.test.js +21 -11
  35. package/dist/services/memory/constants.js +1 -1
  36. package/dist/services/memory/embedder.js +1 -1
  37. package/dist/services/memory/index.d.ts +1 -1
  38. package/dist/services/memory/indexer.d.ts +2 -1
  39. package/dist/services/memory/indexer.js +24 -8
  40. package/dist/services/memory/indexer.test.js +38 -2
  41. package/dist/services/memory/memory.test.js +172 -15
  42. package/dist/services/memory/store.d.ts +6 -1
  43. package/dist/services/memory/store.js +87 -47
  44. package/dist/services/memory/store.test.js +123 -3
  45. package/dist/services/memory/types.d.ts +9 -1
  46. package/dist/services/memory/types.js +2 -1
  47. package/dist/utils/paths.d.ts +2 -0
  48. package/dist/utils/paths.js +2 -1
  49. package/dist/utils/utils.d.ts +1 -0
  50. package/dist/utils/utils.js +3 -3
  51. package/package.json +2 -2
@@ -24,6 +24,9 @@ export { MemoryConfig };
24
24
  export declare function resolveMemoryConfig(settings: ProjectSettings, workspaceRoot: string): MemoryConfig;
25
25
  export declare function loadProjectSettings(projectDir: string): ProjectSettings;
26
26
  export declare function writeProjectSettings(workspaceRoot: string, patch: Partial<ProjectSettings>): void;
27
+ export declare function getGlobalSettingsFile(): string;
28
+ export declare function loadGlobalSettings(): ProjectSettings;
29
+ export declare function writeGlobalSettings(patch: Partial<ProjectSettings>): void;
27
30
  export declare function resolveConversationDigestConfig(settings: ProjectSettings): ConversationDigestSettings | undefined;
28
31
  /**
29
32
  * Sensitive files that agents are FORBIDDEN from reading/writing.
@@ -3,6 +3,7 @@
3
3
  * Shared configuration constants and loaders
4
4
  */
5
5
  import * as fs from 'fs';
6
+ import * as os from 'os';
6
7
  import * as path from 'path';
7
8
  import { DEFAULT_MEMORY_CONFIG } from '../../services/memory/constants.js';
8
9
  import { atomicWriteJsonFile } from '../../utils/files.js';
@@ -65,6 +66,25 @@ export function writeProjectSettings(workspaceRoot, patch) {
65
66
  const current = loadProjectSettings(workspaceRoot);
66
67
  atomicWriteJsonFile(settingsPath, { ...current, ...patch });
67
68
  }
69
+ export function getGlobalSettingsFile() {
70
+ return path.join(os.homedir(), '.agent-kit', 'settings.json');
71
+ }
72
+ export function loadGlobalSettings() {
73
+ const file = getGlobalSettingsFile();
74
+ if (fs.existsSync(file)) {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
77
+ }
78
+ catch (error) {
79
+ console.error(`[agent-kit] Warning: Failed to parse ${file}:`, error);
80
+ }
81
+ }
82
+ return {};
83
+ }
84
+ export function writeGlobalSettings(patch) {
85
+ const current = loadGlobalSettings();
86
+ atomicWriteJsonFile(getGlobalSettingsFile(), { ...current, ...patch });
87
+ }
68
88
  export function resolveConversationDigestConfig(settings) {
69
89
  const digest = settings.memory?.conversationDigest;
70
90
  if (!digest?.initialized || typeof digest.modelId !== 'string' || typeof digest.initializedAt !== 'string') {
@@ -1,16 +1,17 @@
1
- export declare const HANDOFF_TYPES: readonly ["brainstorm", "clarification", "plan", "ticket", "research", "scenario", "investigation"];
1
+ export declare const HANDOFF_TYPES: readonly ["brainstorm", "clarification", "plan", "ticket", "research", "scenario", "investigation", "code"];
2
2
  type HandoffType = (typeof HANDOFF_TYPES)[number];
3
- type CanonicalHandoffType = 'brainstorm' | 'clarification' | 'plan' | 'ticket' | 'research' | 'scenario' | 'investigation';
4
- interface SavedHandoffLocation {
3
+ type CanonicalHandoffType = 'brainstorm' | 'clarification' | 'plan' | 'ticket' | 'research' | 'scenario' | 'investigation' | 'code';
4
+ export interface SavedHandoffFolderLocation {
5
5
  featureSlug: string;
6
6
  canonicalType: CanonicalHandoffType;
7
- filePath: string;
7
+ folderPath: string;
8
8
  relativePath: string;
9
9
  }
10
- export declare function resolveHandoffPath(input: {
10
+ export declare function resolveHandoffFolder(input: {
11
11
  workspaceRoot: string;
12
12
  type: HandoffType;
13
13
  slug: string;
14
- content: string;
15
- }): SavedHandoffLocation;
14
+ primaryContent: string;
15
+ }): SavedHandoffFolderLocation;
16
+ export declare function validateHandoffFilename(name: string): string;
16
17
  export {};
@@ -7,6 +7,7 @@ export const HANDOFF_TYPES = [
7
7
  'research',
8
8
  'scenario',
9
9
  'investigation',
10
+ 'code',
10
11
  ];
11
12
  const TICKET_ID_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/;
12
13
  function findTicketId(value) {
@@ -44,18 +45,25 @@ function deriveFeatureSlug(input) {
44
45
  return contentTicketSlug;
45
46
  return sanitizeFeatureSlug(contentSlugCandidate(input.content)) || 'untitled-handoff';
46
47
  }
47
- export function resolveHandoffPath(input) {
48
+ export function resolveHandoffFolder(input) {
48
49
  const canonicalType = normalizeHandoffType(input.type);
49
50
  const featureSlug = deriveFeatureSlug({
50
51
  requestedSlug: input.slug,
51
- content: input.content,
52
+ content: input.primaryContent,
52
53
  type: canonicalType,
53
54
  });
54
- const relativePath = path.join('.agent-kit', 'handoffs', featureSlug, `${canonicalType}.md`);
55
+ const relativePath = path.join('.agent-kit', 'handoffs', featureSlug, canonicalType);
55
56
  return {
56
57
  featureSlug,
57
58
  canonicalType,
58
- filePath: path.join(input.workspaceRoot, relativePath),
59
+ folderPath: path.join(input.workspaceRoot, relativePath),
59
60
  relativePath,
60
61
  };
61
62
  }
63
+ export function validateHandoffFilename(name) {
64
+ const normalized = name.trim().replace(/^["']|["']$/g, '');
65
+ if (!/^[A-Za-z0-9_-]+\.md$/.test(normalized)) {
66
+ throw new Error(`Unsafe handoff filename: ${name}`);
67
+ }
68
+ return normalized;
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { describe, test } from 'node:test';
4
+ import { resolveHandoffFolder, validateHandoffFilename } from './index.js';
5
+ const WORKSPACE = '/tmp/test-workspace';
6
+ describe('resolveHandoffFolder', () => {
7
+ test('relativePath has no .md suffix', () => {
8
+ const result = resolveHandoffFolder({
9
+ workspaceRoot: WORKSPACE,
10
+ type: 'plan',
11
+ slug: 'my-feature',
12
+ primaryContent: '',
13
+ });
14
+ assert.ok(!result.relativePath.endsWith('.md'), `relativePath must not end with .md, got: ${result.relativePath}`);
15
+ assert.equal(result.relativePath, path.join('.agent-kit', 'handoffs', 'my-feature', 'plan'));
16
+ });
17
+ test('folderPath is absolute under workspaceRoot', () => {
18
+ const result = resolveHandoffFolder({
19
+ workspaceRoot: WORKSPACE,
20
+ type: 'brainstorm',
21
+ slug: 'auth',
22
+ primaryContent: '',
23
+ });
24
+ assert.equal(result.folderPath, path.join(WORKSPACE, '.agent-kit', 'handoffs', 'auth', 'brainstorm'));
25
+ });
26
+ test('ticket-ID extracted from primaryContent when slug is empty', () => {
27
+ const result = resolveHandoffFolder({
28
+ workspaceRoot: WORKSPACE,
29
+ type: 'plan',
30
+ slug: '',
31
+ primaryContent: '# Fix PROJ-456\n\nSome content.',
32
+ });
33
+ assert.equal(result.featureSlug, 'proj-456');
34
+ });
35
+ test('first-heading fallback when no ticket ID in slug or content', () => {
36
+ const result = resolveHandoffFolder({
37
+ workspaceRoot: WORKSPACE,
38
+ type: 'research',
39
+ slug: '',
40
+ primaryContent: '# My Feature\n\nDetail.',
41
+ });
42
+ assert.equal(result.featureSlug, 'my-feature');
43
+ });
44
+ test('"untitled-handoff" fallback when both slug and content are empty', () => {
45
+ const result = resolveHandoffFolder({ workspaceRoot: WORKSPACE, type: 'ticket', slug: '', primaryContent: '' });
46
+ assert.equal(result.featureSlug, 'untitled-handoff');
47
+ });
48
+ test('code handoff resolves under code folder', () => {
49
+ const result = resolveHandoffFolder({
50
+ workspaceRoot: WORKSPACE,
51
+ type: 'code',
52
+ slug: 'execution-decision-ledger',
53
+ primaryContent: '# Execution Report',
54
+ });
55
+ assert.equal(result.canonicalType, 'code');
56
+ assert.equal(result.relativePath, path.join('.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
57
+ assert.equal(result.folderPath, path.join(WORKSPACE, '.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
58
+ });
59
+ });
60
+ describe('validateHandoffFilename', () => {
61
+ test('"README.md" returns unchanged', () => {
62
+ assert.equal(validateHandoffFilename('README.md'), 'README.md');
63
+ });
64
+ test('"DETAIL.md" returns unchanged', () => {
65
+ assert.equal(validateHandoffFilename('DETAIL.md'), 'DETAIL.md');
66
+ });
67
+ test('"index.md" returns unchanged', () => {
68
+ assert.equal(validateHandoffFilename('index.md'), 'index.md');
69
+ });
70
+ test('"../etc/passwd" throws', () => {
71
+ assert.throws(() => validateHandoffFilename('../etc/passwd'), /Unsafe handoff filename/);
72
+ });
73
+ test('"sub/dir.md" throws', () => {
74
+ assert.throws(() => validateHandoffFilename('sub/dir.md'), /Unsafe handoff filename/);
75
+ });
76
+ test('"README" throws — missing .md extension', () => {
77
+ assert.throws(() => validateHandoffFilename('README'), /Unsafe handoff filename/);
78
+ });
79
+ test('empty string throws', () => {
80
+ assert.throws(() => validateHandoffFilename(''), /Unsafe handoff filename/);
81
+ });
82
+ test('"README.txt" throws — wrong extension', () => {
83
+ assert.throws(() => validateHandoffFilename('README.txt'), /Unsafe handoff filename/);
84
+ });
85
+ test('".hidden.md" throws — leading dot', () => {
86
+ assert.throws(() => validateHandoffFilename('.hidden.md'), /Unsafe handoff filename/);
87
+ });
88
+ });
@@ -10,7 +10,6 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
10
  import { registerCoreTools } from '../mcp/core.js';
11
11
  import { registerAgentTools } from '../mcp/agent.js';
12
12
  import { registerIntegrationTools } from '../mcp/integration.js';
13
- import { loadProjectSettings } from '../core/config/index.js';
14
13
  import { registerMemoryTools } from '../mcp/memory.js';
15
14
  import { getWorkspaceRoot } from '../utils/utils.js';
16
15
  const server = new McpServer({
@@ -24,8 +23,7 @@ registerIntegrationTools(server);
24
23
  registerCoreTools(server);
25
24
  registerAgentTools(server);
26
25
  const workspaceRoot = getWorkspaceRoot();
27
- const settings = loadProjectSettings(workspaceRoot);
28
- const memoryIndexer = registerMemoryTools(server, settings, workspaceRoot);
26
+ const memoryIndexer = registerMemoryTools(server, workspaceRoot);
29
27
  // ═══════════════════════════════════════════════════════════════
30
28
  // START SERVER
31
29
  // ═══════════════════════════════════════════════════════════════
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Agent Delegation Tools - Invoke external agent CLIs (Gemini, Claude, Codex)
3
- * Supports handoff file injection and automatic fallback
3
+ * Supports handoff folder references and automatic fallback
4
4
  */
5
5
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  export declare function registerAgentTools(server: McpServer): void;
package/dist/mcp/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Agent Delegation Tools - Invoke external agent CLIs (Gemini, Claude, Codex)
3
- * Supports handoff file injection and automatic fallback
3
+ * Supports handoff folder references and automatic fallback
4
4
  */
5
5
  import { spawn } from 'child_process';
6
6
  import { randomUUID } from 'crypto';
@@ -49,11 +49,11 @@ function initAgentLog(workspaceRoot, slug, agent) {
49
49
  return { logPath: path.relative(workspaceRoot, logFile), logStream };
50
50
  }
51
51
  export function registerAgentTools(server) {
52
- server.tool('kit_trigger_agent', '!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff file (.agent-kit/handoffs/feature-slug/plan.md).', {
52
+ server.tool('kit_trigger_agent', '!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff folder (.agent-kit/handoffs/feature-slug/plan).', {
53
53
  agent: z.enum(['gemini', 'claude', 'codex']).describe('The agent CLI to invoke: "gemini", "claude", or "codex"'),
54
54
  task: z
55
55
  .string()
56
- .describe('Task message or path to a handoff file (e.g., ".agent-kit/handoffs/feature-slug/plan.md")'),
56
+ .describe('Task message or path to a handoff folder (e.g., ".agent-kit/handoffs/feature-slug/plan")'),
57
57
  }, async ({ agent, task }, extra) => {
58
58
  const workspaceRoot = getWorkspaceRoot();
59
59
  let job;
@@ -2,7 +2,14 @@
2
2
  * Core Tools - Extension info and handoff persistence
3
3
  */
4
4
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { mcpText } from '../utils/utils.js';
5
6
  import { DEFAULT_EXTENSIONS } from '../core/config/index.js';
7
+ import { HANDOFF_TYPES } from '../core/handoffs/index.js';
8
+ export declare function handleSaveHandoff(args: {
9
+ type: (typeof HANDOFF_TYPES)[number];
10
+ slug: string;
11
+ files: Record<string, string>;
12
+ }, workspaceRoot: string): ReturnType<typeof mcpText>;
6
13
  /**
7
14
  * Register core tools with MCP server
8
15
  */
package/dist/mcp/core.js CHANGED
@@ -6,41 +6,55 @@ import * as path from 'path';
6
6
  import { z } from 'zod';
7
7
  import { getWorkspaceRoot, mcpText } from '../utils/utils.js';
8
8
  import { DEFAULT_EXTENSIONS } from '../core/config/index.js';
9
- import { HANDOFF_TYPES, resolveHandoffPath } from '../core/handoffs/index.js';
9
+ import { HANDOFF_TYPES, resolveHandoffFolder, validateHandoffFilename } from '../core/handoffs/index.js';
10
+ export function handleSaveHandoff(args, workspaceRoot) {
11
+ try {
12
+ if (Object.keys(args.files).length === 0) {
13
+ return mcpText('Error saving handoff: files cannot be empty');
14
+ }
15
+ for (const name of Object.keys(args.files)) {
16
+ try {
17
+ validateHandoffFilename(name);
18
+ }
19
+ catch (err) {
20
+ return mcpText(`Error saving handoff: ${err instanceof Error ? err.message : err}`);
21
+ }
22
+ }
23
+ const primaryContent = args.files['README.md'] ?? Object.values(args.files)[0];
24
+ const location = resolveHandoffFolder({
25
+ workspaceRoot,
26
+ type: args.type,
27
+ slug: args.slug,
28
+ primaryContent,
29
+ });
30
+ fs.mkdirSync(location.folderPath, { recursive: true });
31
+ for (const [name, content] of Object.entries(args.files)) {
32
+ fs.writeFileSync(path.join(location.folderPath, name), content, 'utf8');
33
+ }
34
+ return mcpText(`✅ Saved to: ${location.folderPath}`);
35
+ }
36
+ catch (error) {
37
+ return mcpText(`Error saving handoff: ${error instanceof Error ? error.message : error}`);
38
+ }
39
+ }
10
40
  /**
11
41
  * Register core tools with MCP server
12
42
  */
13
43
  export function registerCoreTools(server) {
14
44
  // ═══════════════════════════════════════════════════════════════
15
45
  // TOOL: SAVE HANDOFF
16
- // Writes handoff artifacts to the workspace
17
- // Returns the saved file path to use in next-step instructions
46
+ // Writes handoff artifacts to the workspace as a folder of files
47
+ // Returns the saved folder path to use in next-step instructions
18
48
  // ═══════════════════════════════════════════════════════════════
19
- server.tool('kit_save_handoff', `Save a handoff artifact to .agent-kit/handoffs/. Returns the saved file path to use in next-step instructions. Do NOT append version numbers (v2, v3, etc.) to the slug.`, {
49
+ server.tool('kit_save_handoff', `Save a handoff artifact to .agent-kit/handoffs/. Returns the saved folder path to use in next-step instructions. Do NOT append version numbers (v2, v3, etc.) to the slug.`, {
20
50
  type: z.enum(HANDOFF_TYPES).describe('Handoff type'),
21
- content: z.string().describe('Full markdown content to save'),
51
+ files: z
52
+ .record(z.string(), z.string())
53
+ .describe('Markdown files to write into the handoff folder. May include one or many files per call; repeated calls with the same type and slug add or overwrite the provided files. Filenames must match /^[A-Za-z0-9_-]+\\.md$/.'),
22
54
  slug: z
23
55
  .string()
24
- .describe('Short identifier for the filename, e.g. "user-auth" or "PROJ-123". Do NOT append version numbers (v2, v3, etc.).'),
25
- }, async ({ type, content, slug }) => {
26
- try {
27
- const location = resolveHandoffPath({
28
- workspaceRoot: getWorkspaceRoot(),
29
- type,
30
- slug,
31
- content,
32
- });
33
- const handoffDir = path.dirname(location.filePath);
34
- if (!fs.existsSync(handoffDir)) {
35
- fs.mkdirSync(handoffDir, { recursive: true });
36
- }
37
- fs.writeFileSync(location.filePath, content, 'utf8');
38
- return mcpText(`✅ Saved to: ${location.filePath}`);
39
- }
40
- catch (error) {
41
- return mcpText(`Error saving handoff: ${error}`);
42
- }
43
- });
56
+ .describe('Short identifier for the folder, e.g. "user-auth" or "PROJ-123". Do NOT append version numbers (v2, v3, etc.).'),
57
+ }, async (args) => handleSaveHandoff(args, getWorkspaceRoot()));
44
58
  }
45
59
  // Export DEFAULT_EXTENSIONS for backward compatibility
46
60
  export { DEFAULT_EXTENSIONS };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { afterEach, describe, test } from 'node:test';
6
+ import { handleSaveHandoff } from './core.js';
7
+ const tempDirs = [];
8
+ function makeTempWorkspace() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'core-test-'));
10
+ tempDirs.push(dir);
11
+ return dir;
12
+ }
13
+ afterEach(() => {
14
+ while (tempDirs.length > 0) {
15
+ const dir = tempDirs.pop();
16
+ if (dir)
17
+ fs.rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ });
20
+ function resultText(result) {
21
+ return result.content[0].text;
22
+ }
23
+ describe('handleSaveHandoff', () => {
24
+ test('C1: README.md + DETAIL.md written under folder; returns folder path without .md', () => {
25
+ const workspace = makeTempWorkspace();
26
+ const result = handleSaveHandoff({ type: 'brainstorm', slug: 'auth', files: { 'README.md': '# Readme', 'DETAIL.md': '# Detail' } }, workspace);
27
+ const text = resultText(result);
28
+ assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
29
+ assert.ok(!text.endsWith('.md'), 'folder path must not end with .md');
30
+ const folderPath = text.replace('✅ Saved to: ', '');
31
+ assert.ok(fs.existsSync(path.join(folderPath, 'README.md')), 'README.md must exist');
32
+ assert.ok(fs.existsSync(path.join(folderPath, 'DETAIL.md')), 'DETAIL.md must exist');
33
+ assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), '# Readme');
34
+ assert.equal(fs.readFileSync(path.join(folderPath, 'DETAIL.md'), 'utf8'), '# Detail');
35
+ });
36
+ test('C2: single README.md written under folder; returns folder path', () => {
37
+ const workspace = makeTempWorkspace();
38
+ const result = handleSaveHandoff({ type: 'ticket', slug: 'my-ticket', files: { 'README.md': '# Ticket content' } }, workspace);
39
+ const text = resultText(result);
40
+ assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
41
+ const folderPath = text.replace('✅ Saved to: ', '');
42
+ assert.ok(!text.endsWith('.md'), 'folder path must not end with .md');
43
+ assert.ok(fs.existsSync(path.join(folderPath, 'README.md')), 'README.md must exist');
44
+ assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), '# Ticket content');
45
+ });
46
+ test('C3: empty files returns error containing "files cannot be empty"', () => {
47
+ const workspace = makeTempWorkspace();
48
+ const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: {} }, workspace);
49
+ assert.match(resultText(result), /files cannot be empty/);
50
+ });
51
+ test('C4: path-traversal filename returns error containing "Unsafe handoff filename"', () => {
52
+ const workspace = makeTempWorkspace();
53
+ const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: { '../escape.md': 'bad' } }, workspace);
54
+ assert.match(resultText(result), /Unsafe handoff filename/);
55
+ });
56
+ test('C5: filename without .md extension returns error containing "Unsafe handoff filename"', () => {
57
+ const workspace = makeTempWorkspace();
58
+ const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: { README: 'bad' } }, workspace);
59
+ assert.match(resultText(result), /Unsafe handoff filename/);
60
+ });
61
+ test('C6: slug derived from README.md content ticket-ID when slug is empty', () => {
62
+ const workspace = makeTempWorkspace();
63
+ const result = handleSaveHandoff({ type: 'brainstorm', slug: '', files: { 'README.md': '# Fix PROJ-789\n\nDetails.' } }, workspace);
64
+ const text = resultText(result);
65
+ assert.ok(text.includes('proj-789'), `expected proj-789 in path, got: ${text}`);
66
+ });
67
+ test('C7: second call with same slug overwrites files without error (idempotent)', () => {
68
+ const workspace = makeTempWorkspace();
69
+ const args = { type: 'plan', slug: 'idem', files: { 'README.md': 'v1' } };
70
+ const r1 = handleSaveHandoff(args, workspace);
71
+ assert.ok(resultText(r1).startsWith('✅'), `first call failed: ${resultText(r1)}`);
72
+ const argsV2 = { type: 'plan', slug: 'idem', files: { 'README.md': 'v2' } };
73
+ const r2 = handleSaveHandoff(argsV2, workspace);
74
+ assert.ok(resultText(r2).startsWith('✅'), `second call failed: ${resultText(r2)}`);
75
+ const folderPath = resultText(r2).replace('✅ Saved to: ', '');
76
+ assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), 'v2');
77
+ });
78
+ test('C8: code handoff writes REPORT.md and DECISIONS.md without README.md', () => {
79
+ const workspace = makeTempWorkspace();
80
+ const result = handleSaveHandoff({
81
+ type: 'code',
82
+ slug: 'execution-decision-ledger',
83
+ files: {
84
+ 'REPORT.md': '# Code Execution Report',
85
+ 'DECISIONS.md': '# Execution Decision Records',
86
+ },
87
+ }, workspace);
88
+ const text = resultText(result);
89
+ assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
90
+ const folderPath = text.replace('✅ Saved to: ', '');
91
+ assert.equal(folderPath, path.join(workspace, '.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
92
+ assert.ok(fs.existsSync(path.join(folderPath, 'REPORT.md')), 'REPORT.md must exist');
93
+ assert.ok(fs.existsSync(path.join(folderPath, 'DECISIONS.md')), 'DECISIONS.md must exist');
94
+ assert.equal(fs.existsSync(path.join(folderPath, 'README.md')), false);
95
+ assert.equal(fs.readFileSync(path.join(folderPath, 'REPORT.md'), 'utf8'), '# Code Execution Report');
96
+ assert.equal(fs.readFileSync(path.join(folderPath, 'DECISIONS.md'), 'utf8'), '# Execution Decision Records');
97
+ });
98
+ });
@@ -8,8 +8,9 @@ import { type ProjectSettings } from '../core/config/index.js';
8
8
  * Returns the MemoryIndexer so the caller can fire startupIndex() after server.connect().
9
9
  * Returns null when memory is disabled (settings.memory.enabled !== true).
10
10
  */
11
- export declare function registerMemoryTools(server: McpServer, settings: ProjectSettings, workspaceRoot: string, overrides?: {
11
+ export declare function registerMemoryTools(server: McpServer, workspaceRoot: string, overrides?: {
12
12
  indexer?: MemoryIndexer;
13
13
  store?: MemoryStore;
14
14
  config?: MemoryConfig;
15
+ settings?: ProjectSettings;
15
16
  }): MemoryIndexer | null;
@@ -1,31 +1,30 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
2
3
  import { z } from 'zod';
3
4
  import { Embedder } from '../services/memory/embedder.js';
4
5
  import { MemoryIndexer } from '../services/memory/indexer.js';
5
6
  import { MemoryStore } from '../services/memory/store.js';
7
+ import { SOURCE_TYPES } from '../services/memory/types.js';
6
8
  import { initializeConversationDigestModel } from '../services/digest/processor.js';
7
9
  import { DEFAULT_DIGEST_MODEL_ID } from '../services/digest/constants.js';
8
- import { mcpJson, mcpText } from '../utils/utils.js';
9
- import { loadProjectSettings, resolveConversationDigestConfig, resolveMemoryConfig, } from '../core/config/index.js';
10
+ import { formatError, mcpJson, mcpText } from '../utils/utils.js';
11
+ import { loadGlobalSettings, resolveConversationDigestConfig, resolveMemoryConfig, } from '../core/config/index.js';
10
12
  /**
11
13
  * Registers tool handlers onto an already-constructed indexer/store pair.
12
14
  */
13
15
  function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoot = process.cwd()) {
14
- server.tool('kit_memory_search', 'Search persistent memory for context relevant to the current question. Returns semantically relevant results using hybrid dense+BM25+RRF search.', {
15
- query: z.string().min(1).describe('Search query'),
16
+ server.tool('kit_memory_search', 'Search persistent memory for factual or semantic matches (decisions, concepts, entities). Extract concise keywords only, removing stopwords and filler words from the user request before querying. Use kit_memory_recent for temporal or recency questions.', {
17
+ query: z.string().min(1).describe('Keyword-only search query with stopwords removed'),
16
18
  top_k: z.number().int().positive().optional().describe('Number of results to return'),
17
19
  }, async ({ query, top_k }) => {
18
20
  try {
19
21
  const results = await indexer.search(query, top_k ?? config.topK);
20
- if (results.length === 0) {
21
- const degradedNote = !store.vecAvailable
22
- ? '⚠️ Vector search unavailable — showing keyword-only results.\n\n'
23
- : '';
24
- return mcpText(`${degradedNote}No memories found for query: "${query}"`);
25
- }
26
22
  const degradedNote = !store.vecAvailable
27
23
  ? '⚠️ Vector search unavailable — showing keyword-only results.\n\n'
28
24
  : '';
25
+ if (results.length === 0) {
26
+ return mcpText(`${degradedNote}No memories found for query: "${query}"`);
27
+ }
29
28
  const formatted = results
30
29
  .map((r) => {
31
30
  const displaySource = r.chunk.source.replace(/^compiled\//, '');
@@ -38,8 +37,34 @@ function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoo
38
37
  return mcpText(`${degradedNote}${formatted}`);
39
38
  }
40
39
  catch (err) {
41
- const message = err instanceof Error ? err.message : String(err);
42
- return mcpText(`kit_memory_search failed: ${message}`);
40
+ return mcpText(`kit_memory_search failed: ${formatError(err)}`);
41
+ }
42
+ });
43
+ server.tool('kit_memory_recent', 'Return the N most recently updated memory sources. Optionally filter by source type. Use for temporal questions like "what did we do last session" or "recent".', {
44
+ n: z.number().int().positive().max(50).optional().describe('How many recent sources to return (default 5)'),
45
+ source_type: z.enum(SOURCE_TYPES).optional().describe('Optional source type filter'),
46
+ }, async ({ n, source_type }) => {
47
+ try {
48
+ const rows = store.getRecentSources({ limit: n ?? 5, sourceType: source_type });
49
+ if (rows.length === 0)
50
+ return mcpText('No recent sources found.');
51
+ const blocks = [];
52
+ for (const row of rows) {
53
+ try {
54
+ const content = fs.readFileSync(path.join(config.wikiDir, row.source), 'utf8');
55
+ const displaySource = row.source.replace(/^compiled\//, '');
56
+ blocks.push(`### ${displaySource}\n${content}`);
57
+ }
58
+ catch (err) {
59
+ console.warn(`[memory] Skipping unavailable recent source ${row.source}:`, err);
60
+ }
61
+ }
62
+ if (blocks.length === 0)
63
+ return mcpText('No recent sources found.');
64
+ return mcpText(blocks.join('\n\n---\n\n'));
65
+ }
66
+ catch (err) {
67
+ return mcpText(`kit_memory_recent failed: ${formatError(err)}`);
43
68
  }
44
69
  });
45
70
  server.tool('kit_memory_save', 'Save content to wiki/raw for inclusion after the next /wiki compile.', {
@@ -54,8 +79,10 @@ function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoo
54
79
  });
55
80
  }
56
81
  catch (err) {
57
- const message = err instanceof Error ? err.message : String(err);
58
- return mcpJson({ saved: false, error: message });
82
+ return mcpJson({
83
+ saved: false,
84
+ error: formatError(err),
85
+ });
59
86
  }
60
87
  });
61
88
  server.tool('kit_memory_digest_init', 'Initialize or toggle local conversation digesting. Downloads/cache-loads the pinned local model only when explicitly invoked.', {
@@ -63,7 +90,7 @@ function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoo
63
90
  enabled: z.boolean().optional().describe('Enable or disable conversation digesting'),
64
91
  }, async ({ model_id, enabled }) => {
65
92
  try {
66
- const existingConfig = resolveConversationDigestConfig(loadProjectSettings(workspaceRoot));
93
+ const existingConfig = resolveConversationDigestConfig(loadGlobalSettings());
67
94
  const result = await initializeConversationDigestModel({
68
95
  workspaceRoot,
69
96
  modelId: model_id ?? existingConfig?.modelId ?? DEFAULT_DIGEST_MODEL_ID,
@@ -78,8 +105,7 @@ function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoo
78
105
  });
79
106
  }
80
107
  catch (err) {
81
- const message = err instanceof Error ? err.message : String(err);
82
- return mcpJson({ initialized: false, error: message });
108
+ return mcpJson({ initialized: false, error: formatError(err) });
83
109
  }
84
110
  });
85
111
  }
@@ -88,7 +114,8 @@ function registerMemoryToolHandlers(server, indexer, store, config, workspaceRoo
88
114
  * Returns the MemoryIndexer so the caller can fire startupIndex() after server.connect().
89
115
  * Returns null when memory is disabled (settings.memory.enabled !== true).
90
116
  */
91
- export function registerMemoryTools(server, settings, workspaceRoot, overrides) {
117
+ export function registerMemoryTools(server, workspaceRoot, overrides) {
118
+ const settings = overrides?.settings ?? loadGlobalSettings();
92
119
  if (settings.memory?.enabled !== true)
93
120
  return null;
94
121
  const config = overrides?.config ?? resolveMemoryConfig(settings, workspaceRoot);