@aperdomoll90/ledger-ai 1.2.0 → 1.4.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 +19 -4
- package/dist/commands/add.js +1 -1
- package/dist/commands/check.js +13 -1
- package/dist/commands/config.js +2 -2
- package/dist/commands/ingest.js +1 -1
- package/dist/commands/lint.js +179 -0
- package/dist/lib/lint-configs.js +163 -0
- package/dist/lib/notes.js +58 -4
- 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 +5 -4
- package/dist/migrations/migrations/004-upsert-key-unique.sql +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ ledger init # connect to existing Supabase, pull persona, set up platform
|
|
|
41
41
|
|
|
42
42
|
**For you:** Define who you are, how you want AI to behave, your coding conventions, communication style. Do it once. Every AI agent on every device follows the same rules.
|
|
43
43
|
|
|
44
|
-
**For your agents:**
|
|
44
|
+
**For your agents:** A full RAG (Retrieval-Augmented Generation) system. Notes are embedded with OpenAI and stored in Postgres with pgvector. Agents find relevant context by semantic meaning, not keywords. Retrieved context is injected into agent prompts automatically via MCP.
|
|
45
45
|
|
|
46
46
|
**For your workflow:** Automatic sync at session start, conflict detection, session-end checks. Hooks enforce rules (block credential file access, auto-ingest notes to Ledger).
|
|
47
47
|
|
|
@@ -153,10 +153,16 @@ npm run release # version bump → build → test → publish to npm
|
|
|
153
153
|
|
|
154
154
|
## Roadmap
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
### RAG Enhancements
|
|
157
|
+
- Hybrid search (vector + BM25 keyword via PostgreSQL full-text search)
|
|
158
|
+
- Re-ranking — score retrieved chunks before feeding to LLM
|
|
159
|
+
- Chunking strategies for large documents (auto-split on ingest)
|
|
160
|
+
- Multi-format ingest (PDF, Excel, images, audio)
|
|
161
|
+
- Multi-provider embeddings (Supabase built-in, OpenAI, Ollama, Cohere)
|
|
162
|
+
|
|
163
|
+
### Platform
|
|
157
164
|
- Note versioning / history
|
|
158
165
|
- Soft delete (trash)
|
|
159
|
-
- Multi-format ingest (PDF, Excel, images, audio)
|
|
160
166
|
- Web dashboard
|
|
161
167
|
- Skills system (context-aware convention enforcement)
|
|
162
168
|
- VS Code extension
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
|
|
|
6
6
|
const { version } = require('../package.json');
|
|
7
7
|
import { pull } from './commands/pull.js';
|
|
8
8
|
import { push } from './commands/push.js';
|
|
9
|
-
import { check } from './commands/check.js';
|
|
9
|
+
import { check, checkChunks } from './commands/check.js';
|
|
10
10
|
import { sync } from './commands/sync.js';
|
|
11
11
|
import { show } from './commands/show.js';
|
|
12
12
|
import { exportNote } from './commands/export.js';
|
|
@@ -24,6 +24,7 @@ import { update } from './commands/update.js';
|
|
|
24
24
|
import { deleteNote } from './commands/delete.js';
|
|
25
25
|
import { list } from './commands/list.js';
|
|
26
26
|
import { tag } from './commands/tag.js';
|
|
27
|
+
import { lint } from './commands/lint.js';
|
|
27
28
|
process.on('unhandledRejection', (err) => {
|
|
28
29
|
console.error(err instanceof Error ? err.message : String(err));
|
|
29
30
|
process.exit(1);
|
|
@@ -51,10 +52,16 @@ program
|
|
|
51
52
|
});
|
|
52
53
|
program
|
|
53
54
|
.command('check')
|
|
54
|
-
.description('Compare local files vs Ledger, report sync status
|
|
55
|
-
.
|
|
55
|
+
.description('Compare local files vs Ledger, report sync status')
|
|
56
|
+
.option('--chunks', 'Check chunk group integrity')
|
|
57
|
+
.action(async (opts) => {
|
|
56
58
|
const config = loadConfig();
|
|
57
|
-
|
|
59
|
+
if (opts.chunks) {
|
|
60
|
+
await checkChunks(config);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
await check(config);
|
|
64
|
+
}
|
|
58
65
|
});
|
|
59
66
|
program
|
|
60
67
|
.command('sync')
|
|
@@ -272,4 +279,12 @@ program
|
|
|
272
279
|
scope: options.scope,
|
|
273
280
|
});
|
|
274
281
|
});
|
|
282
|
+
program
|
|
283
|
+
.command('lint')
|
|
284
|
+
.description('Add lint configs to the current project based on detected stack')
|
|
285
|
+
.option('--personal', 'include personal conventions (BEM, SCSS patterns)')
|
|
286
|
+
.option('--diff', 'compare local configs against Ledger versions')
|
|
287
|
+
.action(async (options) => {
|
|
288
|
+
await lint({ personal: options.personal ?? false, diff: options.diff ?? false });
|
|
289
|
+
});
|
|
275
290
|
program.parse();
|
package/dist/commands/add.js
CHANGED
|
@@ -39,7 +39,7 @@ export async function add(config, content, options) {
|
|
|
39
39
|
console.error(nameError);
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
|
-
const deliveryChoice = await choose('Delivery tier?', ['persona', 'project', 'knowledge']);
|
|
42
|
+
const deliveryChoice = await choose('Delivery tier?', ['persona', 'project', 'knowledge', 'protected']);
|
|
43
43
|
registerType(type, deliveryChoice);
|
|
44
44
|
console.error(`Registered type "${type}" with delivery "${deliveryChoice}".`);
|
|
45
45
|
}
|
package/dist/commands/check.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
|
-
import { fetchNoteHashes } from '../lib/notes.js';
|
|
3
|
+
import { fetchNoteHashes, checkChunkIntegrity } from '../lib/notes.js';
|
|
4
4
|
import { contentHash } from '../lib/hash.js';
|
|
5
5
|
export async function check(config) {
|
|
6
6
|
const result = {
|
|
@@ -77,3 +77,15 @@ export async function check(config) {
|
|
|
77
77
|
}
|
|
78
78
|
return result;
|
|
79
79
|
}
|
|
80
|
+
export async function checkChunks(config) {
|
|
81
|
+
console.error('Checking chunk integrity...');
|
|
82
|
+
const result = await checkChunkIntegrity(config.supabase);
|
|
83
|
+
if (result.incompleteGroups.length === 0) {
|
|
84
|
+
console.log('All chunk groups are complete.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
console.error(`Found ${result.incompleteGroups.length} incomplete chunk group(s):`);
|
|
88
|
+
for (const group of result.incompleteGroups) {
|
|
89
|
+
console.error(` group ${group.groupId}: expected ${group.expected} chunks, found ${group.found}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/commands/config.js
CHANGED
|
@@ -155,8 +155,8 @@ export async function configSet(key, value, clients) {
|
|
|
155
155
|
if (key.startsWith('types.')) {
|
|
156
156
|
const typeName = key.slice(6);
|
|
157
157
|
const delivery = value;
|
|
158
|
-
if (!['persona', 'project', 'knowledge'].includes(delivery)) {
|
|
159
|
-
console.error(`Invalid delivery tier: "${value}". Must be: persona, project, or
|
|
158
|
+
if (!['persona', 'project', 'knowledge', 'protected'].includes(delivery)) {
|
|
159
|
+
console.error(`Invalid delivery tier: "${value}". Must be: persona, project, knowledge, or protected.`);
|
|
160
160
|
process.exit(1);
|
|
161
161
|
}
|
|
162
162
|
const nameError = validateTypeName(typeName);
|
package/dist/commands/ingest.js
CHANGED
|
@@ -108,7 +108,7 @@ async function ingestFile(config, filePath, existingNotes) {
|
|
|
108
108
|
const defaultDelivery = inferDelivery(noteType);
|
|
109
109
|
const deliveryChoice = await choose(`Delivery tier (default: ${defaultDelivery}):`, [
|
|
110
110
|
`${defaultDelivery} (default)`,
|
|
111
|
-
...['persona', 'project', 'knowledge'].filter(d => d !== defaultDelivery),
|
|
111
|
+
...['persona', 'project', 'knowledge', 'protected'].filter(d => d !== defaultDelivery),
|
|
112
112
|
]);
|
|
113
113
|
const delivery = deliveryChoice.replace(' (default)', '');
|
|
114
114
|
const { openai } = config;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
import { detectStack, getConfigsForStack, ESLINT_TS, ESLINT_TS_REACT, STYLELINT_UNIVERSAL, } from '../lib/lint-configs.js';
|
|
6
|
+
// --- Helpers ---
|
|
7
|
+
async function confirm(question) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve(answer.trim() === 'y' || answer.trim() === 'yes');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
// --- writeConfig ---
|
|
17
|
+
export async function writeConfig(projectDir, config) {
|
|
18
|
+
const filePath = resolve(projectDir, config.filename);
|
|
19
|
+
if (existsSync(filePath)) {
|
|
20
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
21
|
+
if (existing === config.content) {
|
|
22
|
+
console.error(` ${config.filename} — already up to date`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const overwrite = await confirm(` ${config.filename} already exists. Overwrite?`);
|
|
26
|
+
if (!overwrite) {
|
|
27
|
+
console.error(` ${config.filename} — skipped`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(filePath, config.content, 'utf-8');
|
|
32
|
+
console.error(` ${config.filename} — written`);
|
|
33
|
+
}
|
|
34
|
+
function getUniversalConfigs(hasReact) {
|
|
35
|
+
return {
|
|
36
|
+
eslint: { filename: 'eslint.config.js', content: hasReact ? ESLINT_TS_REACT : ESLINT_TS },
|
|
37
|
+
stylelint: { filename: '.stylelintrc.json', content: STYLELINT_UNIVERSAL },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function handleDiff(projectDir, configs, universalConfigs) {
|
|
41
|
+
const toCheck = [];
|
|
42
|
+
if (configs.eslint) {
|
|
43
|
+
toCheck.push({ filename: configs.eslint.filename, template: configs.eslint.content });
|
|
44
|
+
// Also check universal if writing personal
|
|
45
|
+
if (configs.eslint.filename !== universalConfigs.eslint.filename) {
|
|
46
|
+
toCheck.push({ filename: universalConfigs.eslint.filename, template: universalConfigs.eslint.content });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (configs.stylelint) {
|
|
50
|
+
toCheck.push({ filename: configs.stylelint.filename, template: configs.stylelint.content });
|
|
51
|
+
if (configs.stylelint.filename !== universalConfigs.stylelint.filename) {
|
|
52
|
+
toCheck.push({ filename: universalConfigs.stylelint.filename, template: universalConfigs.stylelint.content });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (toCheck.length === 0) {
|
|
56
|
+
console.error('No lint configs applicable for this project stack.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let anyDiff = false;
|
|
60
|
+
for (const { filename, template } of toCheck) {
|
|
61
|
+
const filePath = resolve(projectDir, filename);
|
|
62
|
+
if (!existsSync(filePath)) {
|
|
63
|
+
console.error(` ${filename} — not found (run without --diff to create)`);
|
|
64
|
+
anyDiff = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
68
|
+
if (existing === template) {
|
|
69
|
+
console.error(` ${filename} — up to date`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error(` ${filename} — differs from Ledger template`);
|
|
73
|
+
anyDiff = true;
|
|
74
|
+
const update = await confirm(` Update ${filename} to Ledger version?`);
|
|
75
|
+
if (update) {
|
|
76
|
+
writeFileSync(filePath, template, 'utf-8');
|
|
77
|
+
console.error(` ${filename} — updated`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!anyDiff) {
|
|
82
|
+
console.error('\nAll configs are up to date.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// --- checkDependencies ---
|
|
86
|
+
const ESLINT_TS_DEPS = ['typescript-eslint', 'eslint-plugin-import'];
|
|
87
|
+
const ESLINT_REACT_DEPS = ['eslint-plugin-react', 'eslint-plugin-jsx-a11y'];
|
|
88
|
+
const STYLELINT_DEPS = ['stylelint', 'stylelint-config-standard-scss'];
|
|
89
|
+
export async function checkDependencies(projectDir, stack, personal) {
|
|
90
|
+
const packageJsonPath = resolve(projectDir, 'package.json');
|
|
91
|
+
if (!existsSync(packageJsonPath))
|
|
92
|
+
return;
|
|
93
|
+
let pkg;
|
|
94
|
+
try {
|
|
95
|
+
pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const installed = new Set([
|
|
101
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
102
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
103
|
+
]);
|
|
104
|
+
const needed = [];
|
|
105
|
+
const needsEslint = stack.hasTypeScript || stack.hasReact;
|
|
106
|
+
const needsStylelint = stack.hasScss;
|
|
107
|
+
if (needsEslint) {
|
|
108
|
+
for (const dep of ESLINT_TS_DEPS) {
|
|
109
|
+
if (!installed.has(dep))
|
|
110
|
+
needed.push(dep);
|
|
111
|
+
}
|
|
112
|
+
if (stack.hasReact) {
|
|
113
|
+
for (const dep of ESLINT_REACT_DEPS) {
|
|
114
|
+
if (!installed.has(dep))
|
|
115
|
+
needed.push(dep);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (needsStylelint) {
|
|
120
|
+
for (const dep of STYLELINT_DEPS) {
|
|
121
|
+
if (!installed.has(dep))
|
|
122
|
+
needed.push(dep);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (personal) {
|
|
126
|
+
// no extra deps for personal configs
|
|
127
|
+
}
|
|
128
|
+
if (needed.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
console.error('\nMissing devDependencies:');
|
|
131
|
+
for (const dep of needed) {
|
|
132
|
+
console.error(` - ${dep}`);
|
|
133
|
+
}
|
|
134
|
+
const install = await confirm('\nInstall missing dependencies now?');
|
|
135
|
+
if (install) {
|
|
136
|
+
execSync(`npm install --save-dev ${needed.join(' ')}`, { stdio: 'inherit', cwd: projectDir });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// --- Main lint command ---
|
|
140
|
+
export async function lint(options) {
|
|
141
|
+
const projectDir = process.cwd();
|
|
142
|
+
console.error('Detecting project stack...');
|
|
143
|
+
const stack = detectStack(projectDir);
|
|
144
|
+
console.error(` package.json: ${stack.hasPackageJson}`);
|
|
145
|
+
console.error(` TypeScript: ${stack.hasTypeScript}`);
|
|
146
|
+
console.error(` React: ${stack.hasReact}`);
|
|
147
|
+
console.error(` SCSS: ${stack.hasScss}`);
|
|
148
|
+
console.error('');
|
|
149
|
+
const configs = getConfigsForStack(stack, options.personal);
|
|
150
|
+
const universalConfigs = getUniversalConfigs(stack.hasReact);
|
|
151
|
+
if (!configs.eslint && !configs.stylelint) {
|
|
152
|
+
console.error('No lint configs applicable for this stack (needs TypeScript, React, or SCSS).');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (options.diff) {
|
|
156
|
+
console.error('Comparing local configs against Ledger templates...\n');
|
|
157
|
+
await handleDiff(projectDir, configs, universalConfigs);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.error('Writing lint configs...\n');
|
|
161
|
+
// When personal, also write the universal configs that personal ones extend
|
|
162
|
+
if (options.personal) {
|
|
163
|
+
if (configs.eslint) {
|
|
164
|
+
await writeConfig(projectDir, universalConfigs.eslint);
|
|
165
|
+
}
|
|
166
|
+
if (configs.stylelint) {
|
|
167
|
+
await writeConfig(projectDir, universalConfigs.stylelint);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (configs.eslint) {
|
|
171
|
+
await writeConfig(projectDir, configs.eslint);
|
|
172
|
+
}
|
|
173
|
+
if (configs.stylelint) {
|
|
174
|
+
await writeConfig(projectDir, configs.stylelint);
|
|
175
|
+
}
|
|
176
|
+
console.error('');
|
|
177
|
+
await checkDependencies(projectDir, stack, options.personal);
|
|
178
|
+
console.error('\nDone.');
|
|
179
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
// --- Config Templates ---
|
|
4
|
+
// Keep shared TS rules in sync between ESLINT_TS and ESLINT_TS_REACT
|
|
5
|
+
export const ESLINT_TS = `import tseslint from 'typescript-eslint';
|
|
6
|
+
import importPlugin from 'eslint-plugin-import';
|
|
7
|
+
|
|
8
|
+
export default tseslint.config(
|
|
9
|
+
...tseslint.configs.recommended,
|
|
10
|
+
{
|
|
11
|
+
plugins: {
|
|
12
|
+
import: importPlugin,
|
|
13
|
+
},
|
|
14
|
+
rules: {
|
|
15
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
16
|
+
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
|
17
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
18
|
+
'import/no-default-export': 'error',
|
|
19
|
+
'max-lines': ['warn', { max: 200, skipBlankLines: true, skipComments: true }],
|
|
20
|
+
'no-console': ['warn', { allow: ['error', 'warn'] }],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
`;
|
|
25
|
+
export const ESLINT_TS_REACT = `import tseslint from 'typescript-eslint';
|
|
26
|
+
import reactPlugin from 'eslint-plugin-react';
|
|
27
|
+
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
|
28
|
+
import importPlugin from 'eslint-plugin-import';
|
|
29
|
+
|
|
30
|
+
export default tseslint.config(
|
|
31
|
+
...tseslint.configs.recommended,
|
|
32
|
+
{
|
|
33
|
+
plugins: {
|
|
34
|
+
react: reactPlugin,
|
|
35
|
+
'jsx-a11y': jsxA11yPlugin,
|
|
36
|
+
import: importPlugin,
|
|
37
|
+
},
|
|
38
|
+
settings: {
|
|
39
|
+
react: { version: 'detect' },
|
|
40
|
+
},
|
|
41
|
+
rules: {
|
|
42
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
43
|
+
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
|
44
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
45
|
+
'react/button-has-type': 'error',
|
|
46
|
+
'import/no-default-export': 'error',
|
|
47
|
+
'jsx-a11y/anchor-is-valid': 'warn',
|
|
48
|
+
'jsx-a11y/click-events-have-key-events': 'warn',
|
|
49
|
+
'max-lines': ['warn', { max: 200, skipBlankLines: true, skipComments: true }],
|
|
50
|
+
'no-console': ['warn', { allow: ['error', 'warn'] }],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
`;
|
|
55
|
+
// Backwards compat alias
|
|
56
|
+
export const ESLINT_UNIVERSAL = ESLINT_TS_REACT;
|
|
57
|
+
export const ESLINT_PERSONAL = `import universalConfig from './eslint.config.js';
|
|
58
|
+
|
|
59
|
+
export default [
|
|
60
|
+
...universalConfig,
|
|
61
|
+
{
|
|
62
|
+
rules: {
|
|
63
|
+
'no-warning-comments': ['warn', { terms: ['TODO', 'FIXME', 'HACK'] }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
`;
|
|
68
|
+
export const STYLELINT_UNIVERSAL = JSON.stringify({
|
|
69
|
+
extends: ['stylelint-config-standard-scss'],
|
|
70
|
+
rules: {
|
|
71
|
+
'unit-disallowed-list': ['vh', 'vw'],
|
|
72
|
+
'declaration-property-unit-allowed-list': {
|
|
73
|
+
padding: ['rem', '%'],
|
|
74
|
+
margin: ['rem', '%'],
|
|
75
|
+
gap: ['rem', '%'],
|
|
76
|
+
'border-width': ['px'],
|
|
77
|
+
'font-size': ['rem'],
|
|
78
|
+
},
|
|
79
|
+
'color-no-hex': [true, { severity: 'warning' }],
|
|
80
|
+
'declaration-no-important': true,
|
|
81
|
+
'declaration-block-no-duplicate-properties': true,
|
|
82
|
+
'shorthand-property-no-redundant-values': [true, { severity: 'warning' }],
|
|
83
|
+
},
|
|
84
|
+
}, null, 2);
|
|
85
|
+
export const STYLELINT_PERSONAL = JSON.stringify({
|
|
86
|
+
extends: ['./.stylelintrc.json'],
|
|
87
|
+
rules: {
|
|
88
|
+
'selector-class-pattern': [
|
|
89
|
+
'^c-[a-z][a-z0-9]*(__[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*)?$',
|
|
90
|
+
{
|
|
91
|
+
message: 'Class names must use BEM with c- prefix (e.g. c-block__element-modifier)',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
'unit-disallowed-list': ['vh', 'vw', 'dvh'],
|
|
95
|
+
'media-feature-name-disallowed-list': ['max-width'],
|
|
96
|
+
'max-nesting-depth': [3, { severity: 'warning' }],
|
|
97
|
+
},
|
|
98
|
+
}, null, 2);
|
|
99
|
+
function hasScssFiles(dir) {
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(dir);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (entry === 'node_modules')
|
|
109
|
+
continue;
|
|
110
|
+
const fullPath = join(dir, entry);
|
|
111
|
+
let stat;
|
|
112
|
+
try {
|
|
113
|
+
stat = statSync(fullPath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (stat.isDirectory()) {
|
|
119
|
+
if (hasScssFiles(fullPath))
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
else if (entry.endsWith('.scss')) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
export function detectStack(projectDir) {
|
|
129
|
+
const packageJsonPath = resolve(projectDir, 'package.json');
|
|
130
|
+
const tsConfigPath = resolve(projectDir, 'tsconfig.json');
|
|
131
|
+
const hasPackageJson = existsSync(packageJsonPath);
|
|
132
|
+
const hasTypeScript = existsSync(tsConfigPath);
|
|
133
|
+
let hasReact = false;
|
|
134
|
+
if (hasPackageJson) {
|
|
135
|
+
try {
|
|
136
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
137
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
138
|
+
hasReact = 'react' in deps;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// leave hasReact false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const hasScss = hasScssFiles(projectDir);
|
|
145
|
+
return { hasPackageJson, hasTypeScript, hasReact, hasScss };
|
|
146
|
+
}
|
|
147
|
+
export function getConfigsForStack(stack, personal) {
|
|
148
|
+
const needsEslint = stack.hasTypeScript || stack.hasReact;
|
|
149
|
+
const needsStylelint = stack.hasScss;
|
|
150
|
+
const eslint = needsEslint
|
|
151
|
+
? {
|
|
152
|
+
filename: personal ? 'eslint.config.personal.js' : 'eslint.config.js',
|
|
153
|
+
content: personal ? ESLINT_PERSONAL : (stack.hasReact ? ESLINT_TS_REACT : ESLINT_TS),
|
|
154
|
+
}
|
|
155
|
+
: null;
|
|
156
|
+
const stylelint = needsStylelint
|
|
157
|
+
? {
|
|
158
|
+
filename: personal ? '.stylelintrc.personal.json' : '.stylelintrc.json',
|
|
159
|
+
content: personal ? STYLELINT_PERSONAL : STYLELINT_UNIVERSAL,
|
|
160
|
+
}
|
|
161
|
+
: null;
|
|
162
|
+
return { eslint, stylelint };
|
|
163
|
+
}
|
package/dist/lib/notes.js
CHANGED
|
@@ -72,7 +72,6 @@ export async function findNoteByFile(supabase, filename) {
|
|
|
72
72
|
.from('notes')
|
|
73
73
|
.select('id, metadata')
|
|
74
74
|
.eq('metadata->>local_file', filename)
|
|
75
|
-
.limit(1)
|
|
76
75
|
.single();
|
|
77
76
|
if (byFile)
|
|
78
77
|
return byFile;
|
|
@@ -81,7 +80,6 @@ export async function findNoteByFile(supabase, filename) {
|
|
|
81
80
|
.from('notes')
|
|
82
81
|
.select('id, metadata')
|
|
83
82
|
.eq('metadata->>upsert_key', upsertKey)
|
|
84
|
-
.limit(1)
|
|
85
83
|
.single();
|
|
86
84
|
return byKey || null;
|
|
87
85
|
}
|
|
@@ -485,7 +483,6 @@ export async function opAddNote(clients, content, type, agent, metadata, force,
|
|
|
485
483
|
.from('notes')
|
|
486
484
|
.select('id, metadata, created_at')
|
|
487
485
|
.eq('metadata->>upsert_key', upsertKey)
|
|
488
|
-
.limit(1)
|
|
489
486
|
.single();
|
|
490
487
|
if (existing) {
|
|
491
488
|
return upsertExistingNote(clients, existing, content, fullMetadata);
|
|
@@ -586,6 +583,16 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
|
|
|
586
583
|
if (fetchError || !existing) {
|
|
587
584
|
return { status: 'error', message: `Error: note ${id} not found.` };
|
|
588
585
|
}
|
|
586
|
+
// Protected delivery gate — skill-reference and other protected notes require explicit confirmation
|
|
587
|
+
const existingDelivery = existing.metadata.delivery;
|
|
588
|
+
if (existingDelivery === 'protected' && !confirmed) {
|
|
589
|
+
const existingType = existing.metadata.type;
|
|
590
|
+
const uKey = existing.metadata.upsert_key;
|
|
591
|
+
return {
|
|
592
|
+
status: 'confirm',
|
|
593
|
+
message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to update it? Skill reference documents are protected to prevent accidental overwrites.\n\nTo proceed, call update_note again with confirmed: true.`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
589
596
|
// Confirmation gate
|
|
590
597
|
if (!confirmed) {
|
|
591
598
|
const currentPreview = formatNotePreview(existing.id, existing.metadata, existing.content);
|
|
@@ -657,7 +664,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
|
|
|
657
664
|
}
|
|
658
665
|
return { status: 'ok', message: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` };
|
|
659
666
|
}
|
|
660
|
-
export async function opUpdateMetadata(clients, id, metadata) {
|
|
667
|
+
export async function opUpdateMetadata(clients, id, metadata, confirmed = false) {
|
|
661
668
|
const { data: existing, error: fetchError } = await clients.supabase
|
|
662
669
|
.from('notes')
|
|
663
670
|
.select('id, metadata')
|
|
@@ -666,6 +673,16 @@ export async function opUpdateMetadata(clients, id, metadata) {
|
|
|
666
673
|
if (fetchError || !existing) {
|
|
667
674
|
return { status: 'error', message: `Error: note ${id} not found.` };
|
|
668
675
|
}
|
|
676
|
+
// Protected delivery gate
|
|
677
|
+
const existingDelivery = existing.metadata.delivery;
|
|
678
|
+
if (existingDelivery === 'protected' && !confirmed) {
|
|
679
|
+
const existingType = existing.metadata.type;
|
|
680
|
+
const uKey = existing.metadata.upsert_key;
|
|
681
|
+
return {
|
|
682
|
+
status: 'confirm',
|
|
683
|
+
message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to update its metadata?\n\nTo proceed, call update_metadata again with confirmed: true.`,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
669
686
|
const merged = { ...existing.metadata, ...metadata };
|
|
670
687
|
const { error } = await clients.supabase
|
|
671
688
|
.from('notes')
|
|
@@ -687,6 +704,15 @@ export async function opDeleteNote(clients, id, confirmed = false) {
|
|
|
687
704
|
}
|
|
688
705
|
const meta = existing.metadata;
|
|
689
706
|
const groupId = meta.chunk_group;
|
|
707
|
+
// Protected delivery gate
|
|
708
|
+
if (meta.delivery === 'protected' && !confirmed) {
|
|
709
|
+
const uKey = meta.upsert_key;
|
|
710
|
+
const existingType = meta.type;
|
|
711
|
+
return {
|
|
712
|
+
status: 'confirm',
|
|
713
|
+
message: `⚠️ PROTECTED NOTE — "${uKey || `id ${id}`}" (type: ${existingType || 'unknown'}) has protected delivery.\n\nThis is a skill-reference note. Are you sure you want to delete it? This action cannot be undone.\n\nTo proceed, call delete_note again with confirmed: true.`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
690
716
|
// Confirmation gate
|
|
691
717
|
if (!confirmed) {
|
|
692
718
|
const chunkInfo = groupId ? ` (chunked, all chunks will be deleted)` : '';
|
|
@@ -707,3 +733,31 @@ export async function opDeleteNote(clients, id, confirmed = false) {
|
|
|
707
733
|
return { status: 'error', message: `Error: ${error.message}` };
|
|
708
734
|
return { status: 'ok', message: `Note ${id} deleted.` };
|
|
709
735
|
}
|
|
736
|
+
export async function checkChunkIntegrity(supabase) {
|
|
737
|
+
const { data: chunkedNotes, error } = await supabase
|
|
738
|
+
.from('notes')
|
|
739
|
+
.select('id, metadata')
|
|
740
|
+
.not('metadata->>chunk_group', 'is', null);
|
|
741
|
+
if (error || !chunkedNotes)
|
|
742
|
+
return { incompleteGroups: [] };
|
|
743
|
+
const groups = new Map();
|
|
744
|
+
for (const note of chunkedNotes) {
|
|
745
|
+
const meta = note.metadata;
|
|
746
|
+
const groupId = meta.chunk_group;
|
|
747
|
+
if (!groups.has(groupId))
|
|
748
|
+
groups.set(groupId, []);
|
|
749
|
+
groups.get(groupId).push({
|
|
750
|
+
id: note.id,
|
|
751
|
+
index: meta.chunk_index,
|
|
752
|
+
total: meta.total_chunks,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const incompleteGroups = [];
|
|
756
|
+
for (const [groupId, chunks] of groups) {
|
|
757
|
+
const expected = chunks[0].total;
|
|
758
|
+
if (chunks.length !== expected) {
|
|
759
|
+
incompleteGroups.push({ groupId, expected, found: chunks.length });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return { incompleteGroups };
|
|
763
|
+
}
|
|
@@ -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
|
@@ -81,12 +81,13 @@ server.tool('delete_note', 'Delete a note from the knowledge base by ID. If the
|
|
|
81
81
|
const result = await opDeleteNote(clients, id, confirmed);
|
|
82
82
|
return { content: [{ type: 'text', text: result.message }] };
|
|
83
83
|
});
|
|
84
|
-
// Tool: Update metadata only (
|
|
85
|
-
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.', {
|
|
86
86
|
id: z.coerce.number().describe('The note ID to update'),
|
|
87
87
|
metadata: z.record(z.string(), z.unknown()).describe('Metadata fields to merge (existing fields are preserved unless overwritten)'),
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
90
91
|
return { content: [{ type: 'text', text: result.message }] };
|
|
91
92
|
});
|
|
92
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