@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
package/dist/commands/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { loadConfigFile, saveConfigFile } from '../lib/config.js';
|
|
4
|
+
import { fetchPersonaNotes, updateNoteContent, updateNoteHash, opAddNote } from '../lib/notes.js';
|
|
4
5
|
import { contentHash } from '../lib/hash.js';
|
|
5
6
|
import { generateClaudeMd, generateMemoryMd } from '../lib/generators.js';
|
|
6
7
|
import { confirm } from '../lib/prompt.js';
|
|
@@ -20,6 +21,8 @@ export async function sync(config, options) {
|
|
|
20
21
|
return result;
|
|
21
22
|
}
|
|
22
23
|
mkdirSync(config.memoryDir, { recursive: true });
|
|
24
|
+
// --- Phase 0: Sync type registry ---
|
|
25
|
+
await syncTypeRegistryPull(config, quiet, force, dryRun);
|
|
23
26
|
const notesByFile = new Map();
|
|
24
27
|
for (const note of notes) {
|
|
25
28
|
const localFile = note.metadata.local_file;
|
|
@@ -191,6 +194,8 @@ export async function sync(config, options) {
|
|
|
191
194
|
console.error(' wrote ~/CLAUDE.md');
|
|
192
195
|
}
|
|
193
196
|
}
|
|
197
|
+
// --- Phase 3.5: Push type registry ---
|
|
198
|
+
await syncTypeRegistryPush(config, quiet, dryRun);
|
|
194
199
|
// --- Summary ---
|
|
195
200
|
if (!quiet) {
|
|
196
201
|
const parts = [
|
|
@@ -204,3 +209,69 @@ export async function sync(config, options) {
|
|
|
204
209
|
}
|
|
205
210
|
return result;
|
|
206
211
|
}
|
|
212
|
+
export async function syncTypeRegistryPush(config, quiet, dryRun) {
|
|
213
|
+
const configFile = loadConfigFile();
|
|
214
|
+
const userTypes = configFile.types;
|
|
215
|
+
if (!userTypes || Object.keys(userTypes).length === 0) {
|
|
216
|
+
return; // Nothing to push
|
|
217
|
+
}
|
|
218
|
+
const content = JSON.stringify(userTypes, null, 2);
|
|
219
|
+
const clients = { supabase: config.supabase, openai: config.openai };
|
|
220
|
+
if (dryRun) {
|
|
221
|
+
if (!quiet)
|
|
222
|
+
console.error(' type-registry — would push to Ledger');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await opAddNote(clients, content, 'system-rule', 'ledger-sync', {
|
|
226
|
+
upsert_key: 'system-rule-type-registry',
|
|
227
|
+
description: 'User-defined type registry overrides. Managed by ledger sync.',
|
|
228
|
+
delivery: 'persona',
|
|
229
|
+
scope: 'system',
|
|
230
|
+
interactive_skip: true,
|
|
231
|
+
}, true); // force: true to skip duplicate guard
|
|
232
|
+
if (!quiet)
|
|
233
|
+
console.error(' type-registry — pushed to Ledger');
|
|
234
|
+
}
|
|
235
|
+
export async function syncTypeRegistryPull(config, quiet, force, dryRun) {
|
|
236
|
+
const { data: note } = await config.supabase
|
|
237
|
+
.from('notes')
|
|
238
|
+
.select('content')
|
|
239
|
+
.eq('metadata->>upsert_key', 'system-rule-type-registry')
|
|
240
|
+
.limit(1)
|
|
241
|
+
.single();
|
|
242
|
+
if (!note)
|
|
243
|
+
return; // No remote type registry
|
|
244
|
+
let remoteTypes;
|
|
245
|
+
try {
|
|
246
|
+
remoteTypes = JSON.parse(note.content);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
if (!quiet)
|
|
250
|
+
console.error(' type-registry — invalid JSON in remote note, skipping');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const configFile = loadConfigFile();
|
|
254
|
+
const localTypes = configFile.types ?? {};
|
|
255
|
+
// Merge: local wins unless --force
|
|
256
|
+
const merged = force
|
|
257
|
+
? { ...localTypes, ...remoteTypes }
|
|
258
|
+
: { ...remoteTypes, ...localTypes };
|
|
259
|
+
// Check if anything changed
|
|
260
|
+
const localJson = JSON.stringify(localTypes, Object.keys(localTypes).sort());
|
|
261
|
+
const mergedJson = JSON.stringify(merged, Object.keys(merged).sort());
|
|
262
|
+
if (localJson === mergedJson)
|
|
263
|
+
return; // No changes
|
|
264
|
+
if (dryRun) {
|
|
265
|
+
const newKeys = Object.keys(merged).filter(k => !(k in localTypes));
|
|
266
|
+
if (newKeys.length > 0 && !quiet) {
|
|
267
|
+
console.error(` type-registry — would add: ${newKeys.map(k => `${k} (${merged[k]})`).join(', ')}`);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
configFile.types = merged;
|
|
272
|
+
saveConfigFile(configFile);
|
|
273
|
+
const newKeys = Object.keys(merged).filter(k => !(k in localTypes));
|
|
274
|
+
if (newKeys.length > 0 && !quiet) {
|
|
275
|
+
console.error(` type-registry synced: added ${newKeys.map(k => `${k} (${merged[k]})`).join(', ')}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createClient } from '@supabase/supabase-js';
|
|
|
3
3
|
import OpenAI from 'openai';
|
|
4
4
|
import { resolve } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
7
|
import { fatal, ExitCode } from './errors.js';
|
|
8
8
|
// --- Defaults ---
|
|
9
9
|
const LEDGER_DIR = resolve(homedir(), '.ledger');
|
|
@@ -28,6 +28,11 @@ export function loadConfigFile() {
|
|
|
28
28
|
}
|
|
29
29
|
return {};
|
|
30
30
|
}
|
|
31
|
+
export function saveConfigFile(config) {
|
|
32
|
+
const configPath = resolve(getLedgerDir(), 'config.json');
|
|
33
|
+
mkdirSync(getLedgerDir(), { recursive: true });
|
|
34
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
35
|
+
}
|
|
31
36
|
export function getDefaultConfig() {
|
|
32
37
|
return {
|
|
33
38
|
memoryDir: DEFAULT_MEMORY_DIR,
|
|
@@ -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
|
@@ -1,19 +1,60 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { fatal, ExitCode } from './errors.js';
|
|
3
3
|
import { contentHash } from './hash.js';
|
|
4
|
-
import { loadConfigFile } from './config.js';
|
|
5
|
-
|
|
4
|
+
import { loadConfigFile, saveConfigFile } from './config.js';
|
|
5
|
+
// --- Built-in Type Registry ---
|
|
6
|
+
export const BUILTIN_TYPES = {
|
|
6
7
|
'user-preference': 'persona',
|
|
7
|
-
'
|
|
8
|
+
'persona-rule': 'persona',
|
|
9
|
+
'system-rule': 'persona',
|
|
10
|
+
'code-craft': 'persona',
|
|
8
11
|
'architecture-decision': 'project',
|
|
9
12
|
'project-status': 'project',
|
|
10
|
-
'error': 'project',
|
|
11
13
|
'event': 'project',
|
|
14
|
+
'error': 'project',
|
|
12
15
|
'reference': 'knowledge',
|
|
16
|
+
'knowledge-guide': 'knowledge',
|
|
13
17
|
'general': 'knowledge',
|
|
14
18
|
};
|
|
19
|
+
const TYPE_ALIASES = {
|
|
20
|
+
'feedback': 'general',
|
|
21
|
+
};
|
|
22
|
+
function resolveTypeAlias(type) {
|
|
23
|
+
return TYPE_ALIASES[type] ?? type;
|
|
24
|
+
}
|
|
25
|
+
export function getTypeRegistry() {
|
|
26
|
+
const config = loadConfigFile();
|
|
27
|
+
return { ...BUILTIN_TYPES, ...(config.types ?? {}) };
|
|
28
|
+
}
|
|
15
29
|
export function inferDelivery(noteType) {
|
|
16
|
-
|
|
30
|
+
const resolved = resolveTypeAlias(noteType);
|
|
31
|
+
return getTypeRegistry()[resolved] ?? 'knowledge';
|
|
32
|
+
}
|
|
33
|
+
export function getRegisteredTypes() {
|
|
34
|
+
return Object.keys(getTypeRegistry());
|
|
35
|
+
}
|
|
36
|
+
export function isRegisteredType(noteType) {
|
|
37
|
+
const resolved = resolveTypeAlias(noteType);
|
|
38
|
+
return resolved in getTypeRegistry();
|
|
39
|
+
}
|
|
40
|
+
export function registerType(name, delivery) {
|
|
41
|
+
const config = loadConfigFile();
|
|
42
|
+
if (!config.types)
|
|
43
|
+
config.types = {};
|
|
44
|
+
config.types[name] = delivery;
|
|
45
|
+
saveConfigFile(config);
|
|
46
|
+
}
|
|
47
|
+
export function validateTypeName(name) {
|
|
48
|
+
if (!name || name.length < 2) {
|
|
49
|
+
return `Type name must be at least 2 characters. Got ${name.length}.`;
|
|
50
|
+
}
|
|
51
|
+
if (name.length > 50) {
|
|
52
|
+
return `Type name must be 50 characters or fewer. Got ${name.length}.`;
|
|
53
|
+
}
|
|
54
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) {
|
|
55
|
+
return `Invalid type name "${name}". Use lowercase alphanumeric + hyphens, starting with a letter (e.g., "wine-log").`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
17
58
|
}
|
|
18
59
|
// --- Queries ---
|
|
19
60
|
export async function fetchPersonaNotes(supabase) {
|
|
@@ -31,7 +72,6 @@ export async function findNoteByFile(supabase, filename) {
|
|
|
31
72
|
.from('notes')
|
|
32
73
|
.select('id, metadata')
|
|
33
74
|
.eq('metadata->>local_file', filename)
|
|
34
|
-
.limit(1)
|
|
35
75
|
.single();
|
|
36
76
|
if (byFile)
|
|
37
77
|
return byFile;
|
|
@@ -40,7 +80,6 @@ export async function findNoteByFile(supabase, filename) {
|
|
|
40
80
|
.from('notes')
|
|
41
81
|
.select('id, metadata')
|
|
42
82
|
.eq('metadata->>upsert_key', upsertKey)
|
|
43
|
-
.limit(1)
|
|
44
83
|
.single();
|
|
45
84
|
return byKey || null;
|
|
46
85
|
}
|
|
@@ -164,12 +203,16 @@ function formatNotePreview(id, meta, content, maxLen = 300) {
|
|
|
164
203
|
const TYPE_PREFIXES = {
|
|
165
204
|
'feedback': ['feedback'],
|
|
166
205
|
'user-preference': ['user'],
|
|
206
|
+
'persona-rule': ['persona-rule'],
|
|
207
|
+
'system-rule': ['system-rule'],
|
|
208
|
+
'code-craft': ['code-craft'],
|
|
167
209
|
'architecture-decision': ['spec', 'architecture'],
|
|
168
210
|
'project-status': ['project-status'],
|
|
169
211
|
'reference': ['reference'],
|
|
170
212
|
'event': ['devlog', 'event'],
|
|
171
213
|
'error': ['errorlog', 'error'],
|
|
172
214
|
'general': ['general'],
|
|
215
|
+
'knowledge-guide': ['knowledge-guide'],
|
|
173
216
|
};
|
|
174
217
|
/**
|
|
175
218
|
* Validate upsert_key format: {prefix}-{topic} or {project}-{prefix}-{topic}
|
|
@@ -224,18 +267,6 @@ function isInteractive() {
|
|
|
224
267
|
const config = loadConfigFile();
|
|
225
268
|
return config.naming?.interactive !== false;
|
|
226
269
|
}
|
|
227
|
-
/** Valid note types for the interactive prompt. */
|
|
228
|
-
export const NOTE_TYPES = [
|
|
229
|
-
'user-preference',
|
|
230
|
-
'feedback',
|
|
231
|
-
'architecture-decision',
|
|
232
|
-
'project-status',
|
|
233
|
-
'reference',
|
|
234
|
-
'event',
|
|
235
|
-
'error',
|
|
236
|
-
'knowledge-guide',
|
|
237
|
-
'general',
|
|
238
|
-
];
|
|
239
270
|
/** Valid statuses for notes. */
|
|
240
271
|
export const NOTE_STATUSES = ['idea', 'planning', 'active', 'done'];
|
|
241
272
|
/**
|
|
@@ -251,8 +282,7 @@ export function checkMetadataCompleteness(metadata, type) {
|
|
|
251
282
|
missing.push('upsert_key');
|
|
252
283
|
}
|
|
253
284
|
// Only ask for status on project-scoped types
|
|
254
|
-
|
|
255
|
-
if (projectTypes.includes(type) && !metadata.status) {
|
|
285
|
+
if (inferDelivery(type) === 'project' && !metadata.status) {
|
|
256
286
|
missing.push('status');
|
|
257
287
|
}
|
|
258
288
|
if (missing.length === 0)
|
|
@@ -367,7 +397,7 @@ export async function opListNotes(clients, limit, type, project) {
|
|
|
367
397
|
}).join('\n\n---\n\n');
|
|
368
398
|
return { status: 'ok', message: formatted };
|
|
369
399
|
}
|
|
370
|
-
export async function opAddNote(clients, content, type, agent, metadata, force) {
|
|
400
|
+
export async function opAddNote(clients, content, type, agent, metadata, force, registerTypeFlag) {
|
|
371
401
|
const upsertKey = metadata.upsert_key;
|
|
372
402
|
const description = metadata.description;
|
|
373
403
|
const skippedInteractive = metadata.interactive_skip === true;
|
|
@@ -381,6 +411,29 @@ export async function opAddNote(clients, content, type, agent, metadata, force)
|
|
|
381
411
|
}
|
|
382
412
|
// Clean up the skip flag before saving
|
|
383
413
|
delete metadata.interactive_skip;
|
|
414
|
+
// --- Type resolution and registration ---
|
|
415
|
+
const resolvedType = resolveTypeAlias(type);
|
|
416
|
+
// If register_type flag is set and type is unknown, register it
|
|
417
|
+
if (registerTypeFlag && !isRegisteredType(resolvedType)) {
|
|
418
|
+
const nameError = validateTypeName(resolvedType);
|
|
419
|
+
if (nameError)
|
|
420
|
+
return { status: 'error', message: nameError };
|
|
421
|
+
const delivery = metadata.delivery || 'knowledge';
|
|
422
|
+
registerType(resolvedType, delivery);
|
|
423
|
+
}
|
|
424
|
+
// If type is still unknown after potential registration, prompt
|
|
425
|
+
if (!isRegisteredType(resolvedType)) {
|
|
426
|
+
const registry = getTypeRegistry();
|
|
427
|
+
const typeListStr = Object.entries(registry)
|
|
428
|
+
.map(([t, d]) => `${t} (${d})`)
|
|
429
|
+
.join(', ');
|
|
430
|
+
return {
|
|
431
|
+
status: 'confirm',
|
|
432
|
+
message: `Type "${type}" is not registered.\n\nOptions:\n1. Register with delivery "knowledge" (default) — re-call add_note with register_type: true\n2. Register with specific delivery — re-call add_note with register_type: true AND set metadata.delivery to "persona", "project", or "knowledge"\n3. Use an existing type instead — re-call add_note with one of: ${typeListStr}\n4. Cancel\n\nAsk the user which option they prefer.`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// Use resolved type for the rest of the flow
|
|
436
|
+
type = resolvedType;
|
|
384
437
|
// Naming enforcement (opt-in via config)
|
|
385
438
|
if (isNamingEnforced()) {
|
|
386
439
|
const namingError = validateNaming(upsertKey || '', type, description);
|
|
@@ -430,7 +483,6 @@ export async function opAddNote(clients, content, type, agent, metadata, force)
|
|
|
430
483
|
.from('notes')
|
|
431
484
|
.select('id, metadata, created_at')
|
|
432
485
|
.eq('metadata->>upsert_key', upsertKey)
|
|
433
|
-
.limit(1)
|
|
434
486
|
.single();
|
|
435
487
|
if (existing) {
|
|
436
488
|
return upsertExistingNote(clients, existing, content, fullMetadata);
|
|
@@ -531,6 +583,16 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
|
|
|
531
583
|
if (fetchError || !existing) {
|
|
532
584
|
return { status: 'error', message: `Error: note ${id} not found.` };
|
|
533
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
|
+
}
|
|
534
596
|
// Confirmation gate
|
|
535
597
|
if (!confirmed) {
|
|
536
598
|
const currentPreview = formatNotePreview(existing.id, existing.metadata, existing.content);
|
|
@@ -602,7 +664,7 @@ export async function opUpdateNote(clients, id, content, metadata, confirmed = f
|
|
|
602
664
|
}
|
|
603
665
|
return { status: 'ok', message: `Note updated as ${chunks.length} chunks (ids: ${ids.join(', ')}, group: ${newGroupId})` };
|
|
604
666
|
}
|
|
605
|
-
export async function opUpdateMetadata(clients, id, metadata) {
|
|
667
|
+
export async function opUpdateMetadata(clients, id, metadata, confirmed = false) {
|
|
606
668
|
const { data: existing, error: fetchError } = await clients.supabase
|
|
607
669
|
.from('notes')
|
|
608
670
|
.select('id, metadata')
|
|
@@ -611,6 +673,16 @@ export async function opUpdateMetadata(clients, id, metadata) {
|
|
|
611
673
|
if (fetchError || !existing) {
|
|
612
674
|
return { status: 'error', message: `Error: note ${id} not found.` };
|
|
613
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
|
+
}
|
|
614
686
|
const merged = { ...existing.metadata, ...metadata };
|
|
615
687
|
const { error } = await clients.supabase
|
|
616
688
|
.from('notes')
|
|
@@ -632,6 +704,15 @@ export async function opDeleteNote(clients, id, confirmed = false) {
|
|
|
632
704
|
}
|
|
633
705
|
const meta = existing.metadata;
|
|
634
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
|
+
}
|
|
635
716
|
// Confirmation gate
|
|
636
717
|
if (!confirmed) {
|
|
637
718
|
const chunkInfo = groupId ? ` (chunked, all chunks will be deleted)` : '';
|
|
@@ -652,3 +733,31 @@ export async function opDeleteNote(clients, id, confirmed = false) {
|
|
|
652
733
|
return { status: 'error', message: `Error: ${error.message}` };
|
|
653
734
|
return { status: 'ok', message: `Note ${id} deleted.` };
|
|
654
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
|
+
}
|