@aperdomoll90/ledger-ai 1.1.3 → 1.3.0
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.
- package/README.md +9 -3
- package/dist/cli.js +39 -6
- package/dist/commands/add.js +28 -4
- package/dist/commands/check.js +13 -1
- package/dist/commands/config.js +158 -27
- package/dist/commands/ingest.js +1 -1
- package/dist/commands/lint.js +179 -0
- package/dist/commands/sync.js +72 -1
- package/dist/lib/config.js +6 -1
- package/dist/lib/lint-configs.js +163 -0
- package/dist/lib/notes.js +133 -24
- package/dist/lib/op-add-note-types.test.js +159 -0
- package/dist/lib/type-registry.test.js +258 -0
- package/dist/mcp-server.js +15 -8
- package/dist/migrations/migrations/004-upsert-key-unique.sql +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for opAddNote's type registry integration:
|
|
4
|
+
* - Unknown type → confirm prompt
|
|
5
|
+
* - Unknown type + register_type → registers then saves
|
|
6
|
+
* - Unknown type + register_type + invalid name → error
|
|
7
|
+
* - Alias resolution in add flow
|
|
8
|
+
*
|
|
9
|
+
* These tests mock config (filesystem) and force interactive OFF
|
|
10
|
+
* so we only hit the type-checking code path before any DB calls.
|
|
11
|
+
*/
|
|
12
|
+
const mockConfigState = { current: {} };
|
|
13
|
+
vi.mock('./config.js', () => ({
|
|
14
|
+
loadConfigFile: () => mockConfigState.current,
|
|
15
|
+
saveConfigFile: (config) => { mockConfigState.current = config; },
|
|
16
|
+
}));
|
|
17
|
+
// Mock hash module (used in opAddNote for content_hash)
|
|
18
|
+
vi.mock('./hash.js', () => ({
|
|
19
|
+
contentHash: (s) => `hash-${s.length}`,
|
|
20
|
+
}));
|
|
21
|
+
const { opAddNote, BUILTIN_TYPES } = await import('./notes.js');
|
|
22
|
+
// --- Mock Clients ---
|
|
23
|
+
function createMockClients() {
|
|
24
|
+
const insertResult = { data: { id: 999, created_at: '2026-01-01' }, error: null };
|
|
25
|
+
const selectChain = {
|
|
26
|
+
select: vi.fn().mockReturnThis(),
|
|
27
|
+
eq: vi.fn().mockReturnThis(),
|
|
28
|
+
limit: vi.fn().mockReturnThis(),
|
|
29
|
+
single: vi.fn().mockResolvedValue({ data: null, error: null }),
|
|
30
|
+
};
|
|
31
|
+
const insertChain = {
|
|
32
|
+
select: vi.fn().mockReturnValue({
|
|
33
|
+
single: vi.fn().mockResolvedValue(insertResult),
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
const supabase = {
|
|
37
|
+
from: vi.fn().mockReturnValue({
|
|
38
|
+
...selectChain,
|
|
39
|
+
insert: vi.fn().mockReturnValue(insertChain),
|
|
40
|
+
}),
|
|
41
|
+
rpc: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
42
|
+
};
|
|
43
|
+
const openai = {
|
|
44
|
+
embeddings: {
|
|
45
|
+
create: vi.fn().mockResolvedValue({
|
|
46
|
+
data: [{ embedding: new Array(1536).fill(0) }],
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return { supabase, openai };
|
|
51
|
+
}
|
|
52
|
+
// --- Helpers ---
|
|
53
|
+
function resetConfig() {
|
|
54
|
+
mockConfigState.current = { naming: { interactive: false } };
|
|
55
|
+
}
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
resetConfig();
|
|
58
|
+
});
|
|
59
|
+
// ============================================================
|
|
60
|
+
// Unknown type → confirm prompt
|
|
61
|
+
// ============================================================
|
|
62
|
+
describe('opAddNote — unknown type handling', () => {
|
|
63
|
+
it('returns confirm when type is unknown and register_type is false', async () => {
|
|
64
|
+
const clients = createMockClients();
|
|
65
|
+
const result = await opAddNote(clients, 'Some content', 'wine-log', 'claude-code', { upsert_key: 'test', description: 'test' }, false, // force
|
|
66
|
+
false);
|
|
67
|
+
expect(result.status).toBe('confirm');
|
|
68
|
+
expect(result.message).toContain('wine-log');
|
|
69
|
+
expect(result.message).toContain('not registered');
|
|
70
|
+
expect(result.message).toContain('Options');
|
|
71
|
+
});
|
|
72
|
+
it('confirm message lists all registered types', async () => {
|
|
73
|
+
const clients = createMockClients();
|
|
74
|
+
const result = await opAddNote(clients, 'Content', 'unknown-type', 'agent', { upsert_key: 'test', description: 'test' }, false, false);
|
|
75
|
+
expect(result.status).toBe('confirm');
|
|
76
|
+
// Should list built-in types
|
|
77
|
+
expect(result.message).toContain('code-craft');
|
|
78
|
+
expect(result.message).toContain('architecture-decision');
|
|
79
|
+
expect(result.message).toContain('reference');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
// ============================================================
|
|
83
|
+
// Unknown type + register_type → registers then proceeds
|
|
84
|
+
// ============================================================
|
|
85
|
+
describe('opAddNote — type registration', () => {
|
|
86
|
+
it('registers type and saves note when register_type is true', async () => {
|
|
87
|
+
const clients = createMockClients();
|
|
88
|
+
const result = await opAddNote(clients, 'Tasting notes for 2024 Malbec', 'wine-log', 'claude-code', { upsert_key: 'wine-2024-malbec', description: 'Tasting notes', delivery: 'project' }, false, true);
|
|
89
|
+
// Should succeed (type got registered mid-call)
|
|
90
|
+
expect(result.status).toBe('ok');
|
|
91
|
+
// Type should now be in config
|
|
92
|
+
expect(mockConfigState.current.types?.['wine-log']).toBe('project');
|
|
93
|
+
});
|
|
94
|
+
it('defaults delivery to knowledge when not specified', async () => {
|
|
95
|
+
const clients = createMockClients();
|
|
96
|
+
await opAddNote(clients, 'My recipe content', 'recipe', 'claude-code', { upsert_key: 'recipe-pasta', description: 'Pasta recipe' }, false, true);
|
|
97
|
+
expect(mockConfigState.current.types?.['recipe']).toBe('knowledge');
|
|
98
|
+
});
|
|
99
|
+
it('registered type persists for subsequent calls', async () => {
|
|
100
|
+
const clients = createMockClients();
|
|
101
|
+
// First call: register
|
|
102
|
+
await opAddNote(clients, 'First wine note', 'wine-log', 'claude-code', { upsert_key: 'wine-1', description: 'First', delivery: 'project' }, false, true);
|
|
103
|
+
// Second call: should NOT need register_type anymore
|
|
104
|
+
const result = await opAddNote(clients, 'Second wine note', 'wine-log', 'claude-code', { upsert_key: 'wine-2', description: 'Second' }, false, false);
|
|
105
|
+
expect(result.status).toBe('ok');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
// ============================================================
|
|
109
|
+
// Invalid type name with register_type
|
|
110
|
+
// ============================================================
|
|
111
|
+
describe('opAddNote — invalid type name rejection', () => {
|
|
112
|
+
it('returns error for uppercase type name', async () => {
|
|
113
|
+
const clients = createMockClients();
|
|
114
|
+
const result = await opAddNote(clients, 'Content', 'Wine-Log', 'agent', { upsert_key: 'test', description: 'test' }, false, true);
|
|
115
|
+
expect(result.status).toBe('error');
|
|
116
|
+
expect(result.message).toContain('Invalid type name');
|
|
117
|
+
});
|
|
118
|
+
it('returns error for single-character type name', async () => {
|
|
119
|
+
const clients = createMockClients();
|
|
120
|
+
const result = await opAddNote(clients, 'Content', 'x', 'agent', { upsert_key: 'test', description: 'test' }, false, true);
|
|
121
|
+
expect(result.status).toBe('error');
|
|
122
|
+
expect(result.message).toContain('at least 2');
|
|
123
|
+
});
|
|
124
|
+
it('returns error for type name with underscores', async () => {
|
|
125
|
+
const clients = createMockClients();
|
|
126
|
+
const result = await opAddNote(clients, 'Content', 'wine_log', 'agent', { upsert_key: 'test', description: 'test' }, false, true);
|
|
127
|
+
expect(result.status).toBe('error');
|
|
128
|
+
expect(result.message).toContain('Invalid type name');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ============================================================
|
|
132
|
+
// Alias resolution in opAddNote
|
|
133
|
+
// ============================================================
|
|
134
|
+
describe('opAddNote — alias resolution', () => {
|
|
135
|
+
it('feedback alias resolves to general and saves successfully', async () => {
|
|
136
|
+
const clients = createMockClients();
|
|
137
|
+
const result = await opAddNote(clients, 'Some feedback content', 'feedback', 'claude-code', { upsert_key: 'test-feedback', description: 'Test feedback' }, false, false);
|
|
138
|
+
// 'feedback' aliases to 'general' which is a built-in — should work
|
|
139
|
+
expect(result.status).toBe('ok');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ============================================================
|
|
143
|
+
// Built-in types work without registration
|
|
144
|
+
// ============================================================
|
|
145
|
+
describe('opAddNote — built-in types', () => {
|
|
146
|
+
it('accepts all built-in types without register_type flag', async () => {
|
|
147
|
+
const clients = createMockClients();
|
|
148
|
+
for (const builtinType of Object.keys(BUILTIN_TYPES)) {
|
|
149
|
+
const result = await opAddNote(clients, `Content for ${builtinType}`, builtinType, 'claude-code', {
|
|
150
|
+
upsert_key: `test-${builtinType}`,
|
|
151
|
+
description: `Test ${builtinType}`,
|
|
152
|
+
...(builtinType === 'architecture-decision' || builtinType === 'project-status' || builtinType === 'event' || builtinType === 'error'
|
|
153
|
+
? { status: 'active' }
|
|
154
|
+
: {}),
|
|
155
|
+
}, false, false);
|
|
156
|
+
expect(result.status, `Built-in type "${builtinType}" should be accepted`).toBe('ok');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
// Mock config module — isolates tests from filesystem
|
|
3
|
+
const mockConfigState = { current: {} };
|
|
4
|
+
vi.mock('./config.js', () => ({
|
|
5
|
+
loadConfigFile: () => mockConfigState.current,
|
|
6
|
+
saveConfigFile: (config) => { mockConfigState.current = config; },
|
|
7
|
+
}));
|
|
8
|
+
// Import AFTER mock setup so modules pick up the mocked config
|
|
9
|
+
const { BUILTIN_TYPES, getTypeRegistry, inferDelivery, getRegisteredTypes, isRegisteredType, registerType, validateTypeName, checkMetadataCompleteness, } = await import('./notes.js');
|
|
10
|
+
// --- Helpers ---
|
|
11
|
+
function setUserTypes(types) {
|
|
12
|
+
mockConfigState.current = { types };
|
|
13
|
+
}
|
|
14
|
+
function resetConfig() {
|
|
15
|
+
mockConfigState.current = {};
|
|
16
|
+
}
|
|
17
|
+
// --- Tests ---
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetConfig();
|
|
20
|
+
});
|
|
21
|
+
// ============================================================
|
|
22
|
+
// 1. getTypeRegistry
|
|
23
|
+
// ============================================================
|
|
24
|
+
describe('getTypeRegistry', () => {
|
|
25
|
+
it('returns built-ins when no user config', () => {
|
|
26
|
+
const registry = getTypeRegistry();
|
|
27
|
+
expect(registry).toEqual(BUILTIN_TYPES);
|
|
28
|
+
});
|
|
29
|
+
it('merges user overrides with built-ins', () => {
|
|
30
|
+
setUserTypes({ 'wine-log': 'project' });
|
|
31
|
+
const registry = getTypeRegistry();
|
|
32
|
+
expect(registry['wine-log']).toBe('project');
|
|
33
|
+
expect(registry['code-craft']).toBe('persona'); // built-in still present
|
|
34
|
+
});
|
|
35
|
+
it('user overrides win over built-in defaults', () => {
|
|
36
|
+
setUserTypes({ 'code-craft': 'knowledge' });
|
|
37
|
+
const registry = getTypeRegistry();
|
|
38
|
+
expect(registry['code-craft']).toBe('knowledge');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
// ============================================================
|
|
42
|
+
// 2. inferDelivery
|
|
43
|
+
// ============================================================
|
|
44
|
+
describe('inferDelivery', () => {
|
|
45
|
+
it('returns correct tier for built-in types', () => {
|
|
46
|
+
expect(inferDelivery('persona-rule')).toBe('persona');
|
|
47
|
+
expect(inferDelivery('architecture-decision')).toBe('project');
|
|
48
|
+
expect(inferDelivery('reference')).toBe('knowledge');
|
|
49
|
+
});
|
|
50
|
+
it('respects user overrides', () => {
|
|
51
|
+
setUserTypes({ 'code-craft': 'project' });
|
|
52
|
+
expect(inferDelivery('code-craft')).toBe('project');
|
|
53
|
+
});
|
|
54
|
+
it('defaults unknown types to knowledge', () => {
|
|
55
|
+
expect(inferDelivery('nonexistent-type')).toBe('knowledge');
|
|
56
|
+
});
|
|
57
|
+
it('resolves aliases — feedback maps to general (knowledge)', () => {
|
|
58
|
+
expect(inferDelivery('feedback')).toBe('knowledge');
|
|
59
|
+
});
|
|
60
|
+
it('resolves aliases before checking overrides', () => {
|
|
61
|
+
// Override 'general' (which 'feedback' aliases to)
|
|
62
|
+
setUserTypes({ 'general': 'project' });
|
|
63
|
+
expect(inferDelivery('feedback')).toBe('project');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// ============================================================
|
|
67
|
+
// 3. isRegisteredType
|
|
68
|
+
// ============================================================
|
|
69
|
+
describe('isRegisteredType', () => {
|
|
70
|
+
it('returns true for built-in types', () => {
|
|
71
|
+
expect(isRegisteredType('code-craft')).toBe(true);
|
|
72
|
+
expect(isRegisteredType('event')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it('returns true for custom types', () => {
|
|
75
|
+
setUserTypes({ 'wine-log': 'project' });
|
|
76
|
+
expect(isRegisteredType('wine-log')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('returns false for unknown types', () => {
|
|
79
|
+
expect(isRegisteredType('nonexistent')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('returns true for aliased types (feedback → general)', () => {
|
|
82
|
+
expect(isRegisteredType('feedback')).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// ============================================================
|
|
86
|
+
// 4. getRegisteredTypes
|
|
87
|
+
// ============================================================
|
|
88
|
+
describe('getRegisteredTypes', () => {
|
|
89
|
+
it('returns all built-in type names when no custom types', () => {
|
|
90
|
+
const types = getRegisteredTypes();
|
|
91
|
+
expect(types).toContain('code-craft');
|
|
92
|
+
expect(types).toContain('architecture-decision');
|
|
93
|
+
expect(types).toContain('reference');
|
|
94
|
+
expect(types.length).toBe(Object.keys(BUILTIN_TYPES).length);
|
|
95
|
+
});
|
|
96
|
+
it('includes custom types in the list', () => {
|
|
97
|
+
setUserTypes({ 'wine-log': 'project', 'recipe': 'knowledge' });
|
|
98
|
+
const types = getRegisteredTypes();
|
|
99
|
+
expect(types).toContain('wine-log');
|
|
100
|
+
expect(types).toContain('recipe');
|
|
101
|
+
expect(types.length).toBe(Object.keys(BUILTIN_TYPES).length + 2);
|
|
102
|
+
});
|
|
103
|
+
it('does not duplicate when overriding a built-in', () => {
|
|
104
|
+
setUserTypes({ 'code-craft': 'knowledge' });
|
|
105
|
+
const types = getRegisteredTypes();
|
|
106
|
+
const codeCraftCount = types.filter(t => t === 'code-craft').length;
|
|
107
|
+
expect(codeCraftCount).toBe(1);
|
|
108
|
+
expect(types.length).toBe(Object.keys(BUILTIN_TYPES).length);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
// ============================================================
|
|
112
|
+
// 5. registerType
|
|
113
|
+
// ============================================================
|
|
114
|
+
describe('registerType', () => {
|
|
115
|
+
it('writes a new custom type to config', () => {
|
|
116
|
+
registerType('wine-log', 'project');
|
|
117
|
+
expect(mockConfigState.current.types?.['wine-log']).toBe('project');
|
|
118
|
+
});
|
|
119
|
+
it('does not clobber existing config keys', () => {
|
|
120
|
+
mockConfigState.current = {
|
|
121
|
+
device: { alias: 'test-machine' },
|
|
122
|
+
types: { 'existing': 'knowledge' },
|
|
123
|
+
};
|
|
124
|
+
registerType('wine-log', 'project');
|
|
125
|
+
expect(mockConfigState.current.device?.alias).toBe('test-machine');
|
|
126
|
+
expect(mockConfigState.current.types?.['existing']).toBe('knowledge');
|
|
127
|
+
expect(mockConfigState.current.types?.['wine-log']).toBe('project');
|
|
128
|
+
});
|
|
129
|
+
it('initializes types object if missing', () => {
|
|
130
|
+
mockConfigState.current = {};
|
|
131
|
+
registerType('recipe', 'knowledge');
|
|
132
|
+
expect(mockConfigState.current.types).toBeDefined();
|
|
133
|
+
expect(mockConfigState.current.types?.['recipe']).toBe('knowledge');
|
|
134
|
+
});
|
|
135
|
+
it('registered type becomes discoverable immediately', () => {
|
|
136
|
+
expect(isRegisteredType('wine-log')).toBe(false);
|
|
137
|
+
registerType('wine-log', 'project');
|
|
138
|
+
expect(isRegisteredType('wine-log')).toBe(true);
|
|
139
|
+
expect(inferDelivery('wine-log')).toBe('project');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ============================================================
|
|
143
|
+
// 6. validateTypeName
|
|
144
|
+
// ============================================================
|
|
145
|
+
describe('validateTypeName', () => {
|
|
146
|
+
it('accepts valid names', () => {
|
|
147
|
+
expect(validateTypeName('wine-log')).toBeNull();
|
|
148
|
+
expect(validateTypeName('ab')).toBeNull();
|
|
149
|
+
expect(validateTypeName('my-custom-type-123')).toBeNull();
|
|
150
|
+
expect(validateTypeName('a1')).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
it('rejects empty string', () => {
|
|
153
|
+
expect(validateTypeName('')).toContain('at least 2');
|
|
154
|
+
});
|
|
155
|
+
it('rejects single character', () => {
|
|
156
|
+
expect(validateTypeName('a')).toContain('at least 2');
|
|
157
|
+
});
|
|
158
|
+
it('rejects names over 50 characters', () => {
|
|
159
|
+
const longName = 'a' + '-long'.repeat(10) + '-name';
|
|
160
|
+
expect(validateTypeName(longName.length > 50 ? longName : 'a'.repeat(51))).toContain('50 characters');
|
|
161
|
+
});
|
|
162
|
+
it('rejects uppercase letters', () => {
|
|
163
|
+
expect(validateTypeName('Wine-Log')).toContain('lowercase');
|
|
164
|
+
});
|
|
165
|
+
it('rejects names starting with a number', () => {
|
|
166
|
+
expect(validateTypeName('1-bad')).toContain('lowercase');
|
|
167
|
+
});
|
|
168
|
+
it('rejects special characters', () => {
|
|
169
|
+
expect(validateTypeName('wine_log')).toContain('lowercase');
|
|
170
|
+
expect(validateTypeName('wine.log')).toContain('lowercase');
|
|
171
|
+
expect(validateTypeName('wine log')).toContain('lowercase');
|
|
172
|
+
});
|
|
173
|
+
it('rejects consecutive hyphens', () => {
|
|
174
|
+
expect(validateTypeName('wine--log')).toContain('lowercase');
|
|
175
|
+
});
|
|
176
|
+
it('rejects trailing hyphen', () => {
|
|
177
|
+
expect(validateTypeName('wine-')).toContain('lowercase');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
// ============================================================
|
|
181
|
+
// 7. checkMetadataCompleteness (dynamic delivery check)
|
|
182
|
+
// ============================================================
|
|
183
|
+
describe('checkMetadataCompleteness', () => {
|
|
184
|
+
it('returns null when all fields are present for project type', () => {
|
|
185
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key', status: 'active' }, 'architecture-decision');
|
|
186
|
+
expect(result).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
it('returns null when all fields are present for persona type (no status needed)', () => {
|
|
189
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key' }, 'code-craft');
|
|
190
|
+
expect(result).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
it('prompts for status on project-delivery types', () => {
|
|
193
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key' }, 'architecture-decision');
|
|
194
|
+
expect(result).toContain('status');
|
|
195
|
+
});
|
|
196
|
+
it('does NOT prompt for status on persona-delivery types', () => {
|
|
197
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key' }, 'persona-rule');
|
|
198
|
+
expect(result).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
it('uses inferDelivery for custom types — project custom type requires status', () => {
|
|
201
|
+
setUserTypes({ 'wine-log': 'project' });
|
|
202
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key' }, 'wine-log');
|
|
203
|
+
expect(result).toContain('status');
|
|
204
|
+
});
|
|
205
|
+
it('uses inferDelivery for custom types — knowledge custom type skips status', () => {
|
|
206
|
+
setUserTypes({ 'recipe': 'knowledge' });
|
|
207
|
+
const result = checkMetadataCompleteness({ description: 'test', upsert_key: 'test-key' }, 'recipe');
|
|
208
|
+
expect(result).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
it('prompts for missing description and upsert_key', () => {
|
|
211
|
+
const result = checkMetadataCompleteness({}, 'general');
|
|
212
|
+
expect(result).toContain('description');
|
|
213
|
+
expect(result).toContain('upsert_key');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
// ============================================================
|
|
217
|
+
// 8. Type alias resolution (feedback → general)
|
|
218
|
+
// ============================================================
|
|
219
|
+
describe('type alias resolution', () => {
|
|
220
|
+
it('feedback resolves to general in inferDelivery', () => {
|
|
221
|
+
expect(inferDelivery('feedback')).toBe(inferDelivery('general'));
|
|
222
|
+
});
|
|
223
|
+
it('feedback is recognized as registered', () => {
|
|
224
|
+
expect(isRegisteredType('feedback')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
it('unknown aliases pass through unchanged', () => {
|
|
227
|
+
expect(isRegisteredType('totally-unknown')).toBe(false);
|
|
228
|
+
expect(inferDelivery('totally-unknown')).toBe('knowledge');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
// ============================================================
|
|
232
|
+
// 9. Edge cases
|
|
233
|
+
// ============================================================
|
|
234
|
+
describe('edge cases', () => {
|
|
235
|
+
it('empty config file (no types key) falls back to built-ins', () => {
|
|
236
|
+
mockConfigState.current = {};
|
|
237
|
+
const registry = getTypeRegistry();
|
|
238
|
+
expect(registry).toEqual(BUILTIN_TYPES);
|
|
239
|
+
});
|
|
240
|
+
it('config with empty types object works', () => {
|
|
241
|
+
mockConfigState.current = { types: {} };
|
|
242
|
+
const registry = getTypeRegistry();
|
|
243
|
+
expect(registry).toEqual(BUILTIN_TYPES);
|
|
244
|
+
});
|
|
245
|
+
it('overriding a built-in then unsetting reverts to default', () => {
|
|
246
|
+
setUserTypes({ 'code-craft': 'knowledge' });
|
|
247
|
+
expect(inferDelivery('code-craft')).toBe('knowledge');
|
|
248
|
+
// Simulate unsetting — remove from user types
|
|
249
|
+
mockConfigState.current = {};
|
|
250
|
+
expect(inferDelivery('code-craft')).toBe('persona'); // reverts to built-in
|
|
251
|
+
});
|
|
252
|
+
it('registering mid-session is immediately visible', () => {
|
|
253
|
+
expect(getRegisteredTypes()).not.toContain('wine-log');
|
|
254
|
+
registerType('wine-log', 'project');
|
|
255
|
+
expect(getRegisteredTypes()).toContain('wine-log');
|
|
256
|
+
expect(inferDelivery('wine-log')).toBe('project');
|
|
257
|
+
});
|
|
258
|
+
});
|
package/dist/mcp-server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { createClient } from '@supabase/supabase-js';
|
|
5
5
|
import OpenAI from 'openai';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, } from './lib/notes.js';
|
|
7
|
+
import { opSearchNotes, opListNotes, opAddNote, opUpdateNote, opUpdateMetadata, opDeleteNote, getTypeRegistry, } from './lib/notes.js';
|
|
8
8
|
// --- Clients ---
|
|
9
9
|
const supabaseUrl = process.env.SUPABASE_URL;
|
|
10
10
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
@@ -21,6 +21,11 @@ const clients = {
|
|
|
21
21
|
supabase: createClient(supabaseUrl, supabaseKey),
|
|
22
22
|
openai: new OpenAI({ apiKey: openaiKey }),
|
|
23
23
|
};
|
|
24
|
+
// Build dynamic type description from registry
|
|
25
|
+
const typeRegistry = getTypeRegistry();
|
|
26
|
+
const typeList = Object.entries(typeRegistry)
|
|
27
|
+
.map(([t, d]) => `${t} (${d})`)
|
|
28
|
+
.join(', ');
|
|
24
29
|
// --- MCP Server ---
|
|
25
30
|
const server = new McpServer({
|
|
26
31
|
name: 'ledger',
|
|
@@ -40,12 +45,13 @@ server.tool('search_notes', 'Search memories by meaning using semantic similarit
|
|
|
40
45
|
// Tool: Add a new note
|
|
41
46
|
server.tool('add_note', 'Save a new memory/note to the knowledge base. Large notes are automatically chunked for embedding. Use upsert_key in metadata to update an existing note instead of creating a duplicate.', {
|
|
42
47
|
content: z.string().describe('The note content to save'),
|
|
43
|
-
type: z.
|
|
48
|
+
type: z.string().describe(`Note type. Registered: ${typeList}. Unknown types will prompt for registration.`),
|
|
44
49
|
agent: z.string().describe('Which agent is saving this note (e.g. claude-code, zhuli)'),
|
|
45
50
|
metadata: z.record(z.string(), z.unknown()).default({}).describe('Optional metadata (project, local_file, upsert_key, etc.)'),
|
|
46
51
|
force: z.boolean().default(false).describe('Skip duplicate check and force creation of a new note'),
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
register_type: z.boolean().default(false).describe('Set to true to register an unknown type before saving. Pass delivery in metadata if not using default (knowledge).'),
|
|
53
|
+
}, async ({ content, type, agent, metadata, force, register_type }) => {
|
|
54
|
+
const result = await opAddNote(clients, content, type, agent, metadata, force, register_type);
|
|
49
55
|
return { content: [{ type: 'text', text: result.message }] };
|
|
50
56
|
});
|
|
51
57
|
// Tool: Update an existing note by ID
|
|
@@ -75,12 +81,13 @@ server.tool('delete_note', 'Delete a note from the knowledge base by ID. If the
|
|
|
75
81
|
const result = await opDeleteNote(clients, id, confirmed);
|
|
76
82
|
return { content: [{ type: 'text', text: result.message }] };
|
|
77
83
|
});
|
|
78
|
-
// Tool: Update metadata only (
|
|
79
|
-
server.tool('update_metadata', 'Update metadata fields on an existing note without changing content. Useful for adding descriptions, tags, project, or scope.', {
|
|
84
|
+
// Tool: Update metadata only (confirmation required for protected notes)
|
|
85
|
+
server.tool('update_metadata', 'Update metadata fields on an existing note without changing content. Useful for adding descriptions, tags, project, or scope. Protected notes (delivery: protected) require confirmed: true.', {
|
|
80
86
|
id: z.coerce.number().describe('The note ID to update'),
|
|
81
87
|
metadata: z.record(z.string(), z.unknown()).describe('Metadata fields to merge (existing fields are preserved unless overwritten)'),
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
confirmed: z.boolean().default(false).describe('Set to true to confirm update of protected notes. Required when the note has delivery: protected.'),
|
|
89
|
+
}, async ({ id, metadata, confirmed }) => {
|
|
90
|
+
const result = await opUpdateMetadata(clients, id, metadata, confirmed);
|
|
84
91
|
return { content: [{ type: 'text', text: result.message }] };
|
|
85
92
|
});
|
|
86
93
|
// --- Start ---
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Partial unique index on upsert_key (only where not null)
|
|
2
|
+
-- Using INDEX instead of CONSTRAINT because JSONB->>key expressions
|
|
3
|
+
-- can't be used in ALTER TABLE ADD CONSTRAINT
|
|
4
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_upsert_key
|
|
5
|
+
ON notes ((metadata->>'upsert_key'))
|
|
6
|
+
WHERE metadata->>'upsert_key' IS NOT NULL;
|
package/package.json
CHANGED