gitmem-mcp 1.6.0 → 1.6.2
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/CHANGELOG.md +21 -0
- package/dist/commands/activate.js +87 -43
- package/dist/commands/migrate-local.d.ts +18 -0
- package/dist/commands/migrate-local.js +150 -10
- package/dist/schemas/archive-learning.d.ts +22 -0
- package/dist/schemas/archive-learning.js +15 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/registry.js +3 -1
- package/dist/server.js +9 -0
- package/dist/services/embedding.d.ts +7 -1
- package/dist/services/embedding.js +23 -5
- package/dist/services/transcript-chunker.js +3 -2
- package/dist/services/variant-generation.js +3 -2
- package/dist/services/write-health.d.ts +28 -0
- package/dist/services/write-health.js +79 -0
- package/dist/tools/archive-learning.d.ts +1 -1
- package/dist/tools/archive-learning.js +75 -10
- package/dist/tools/definitions.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.2] - 2026-06-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **`archive_learning` short-ID resolution**: Accepts 8-character ID prefixes (e.g., `6edd41e6`) in addition to full UUIDs, matching the short IDs shown by `recall` and `search`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Write-path health check**: New startup diagnostic detects two silent failure classes — (1) Supabase credentials present but tier resolved to FREE (writes go to local files instead of Supabase), and (2) pro/dev tier but resolved tables don't exist (prefix mismatch). Logs a loud warning at startup instead of failing silently on the first write.
|
|
17
|
+
- **`GITMEM_TABLE_PREFIX` documentation**: Pro setup guide now documents custom table prefix configuration, mismatch symptoms, and troubleshooting steps.
|
|
18
|
+
|
|
19
|
+
## [1.6.1] - 2026-05-25
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Embeddings not generated on Pro tier**: `embedding.ts`, `variant-generation.ts`, and `transcript-chunker.ts` only checked `process.env.OPENROUTER_API_KEY` — never read the key from `.gitmem/config.json` written by `activate`. Now falls back to `getProConfig()` matching how `supabase-client.ts` already resolves credentials.
|
|
23
|
+
- **Lossy free→pro migration**: Migration sent all local JSON fields to PostgREST — unknown columns caused 400 rejections, silently dropping records (only first 3 errors shown). Added `KNOWN_COLUMNS` whitelist per table, type coercion for `action_protocol`/`self_check_criteria` (array→TEXT), and full error visibility (no cap).
|
|
24
|
+
- **Migration log file**: `.gitmem/migration.log` now written with per-record outcomes (OK/FAIL/SKIP) for debugging.
|
|
25
|
+
- **Mid-migration recovery**: `activate` now detects `.pre-migration` backup files from a previous failed upgrade and re-imports them automatically. No new command — just re-run `activate`.
|
|
26
|
+
- **Credential exposure**: `activate` now auto-adds `.gitmem/` to the project's `.gitignore` when inside a git repo.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **E2E stress test v1.4**: Restructured from 6 to 8 simulated days. Day 0 wipes Supabase to blank slate. Day 1 seeds realistic 3+ month free-tier user data (starter scars, unknown fields, type mismatches, mixed projects) and tests full upgrade journey including mid-failure recovery and real embeddings from config.json (not env var). 178 tests total.
|
|
30
|
+
|
|
10
31
|
## [1.6.0] - 2026-05-25
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -22,7 +22,7 @@ import * as readline from "readline";
|
|
|
22
22
|
import { fileURLToPath } from "url";
|
|
23
23
|
import { getGitmemDir, getInstallId } from "../services/gitmem-dir.js";
|
|
24
24
|
import { validateLicense, clearLicenseCache } from "../services/license.js";
|
|
25
|
-
import { hasLocalData, migrateLocalToSupabase, archiveLocalData } from "./migrate-local.js";
|
|
25
|
+
import { hasLocalData, hasPreMigrationData, migrateLocalToSupabase, reimportFromBackups, archiveLocalData } from "./migrate-local.js";
|
|
26
26
|
function createReadline() {
|
|
27
27
|
return readline.createInterface({
|
|
28
28
|
input: process.stdin,
|
|
@@ -485,56 +485,100 @@ export async function main(args) {
|
|
|
485
485
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
486
486
|
// Clear any stale license cache
|
|
487
487
|
clearLicenseCache();
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
let totalMigrated = 0;
|
|
502
|
-
let totalSkipped = 0;
|
|
503
|
-
let totalErrors = 0;
|
|
504
|
-
for (const col of collections) {
|
|
505
|
-
const m = migrationResult.migrated[col];
|
|
506
|
-
const s = migrationResult.skipped[col];
|
|
507
|
-
const e = migrationResult.errors[col]?.length || 0;
|
|
508
|
-
totalMigrated += m;
|
|
509
|
-
totalSkipped += s;
|
|
510
|
-
totalErrors += e;
|
|
511
|
-
if (m > 0) {
|
|
512
|
-
console.log(` ✓ ${col}: ${m} records migrated${s > 0 ? ` (${s} skipped)` : ""}`);
|
|
488
|
+
// Ensure .gitmem/ is in .gitignore (prevents credential exposure)
|
|
489
|
+
try {
|
|
490
|
+
const projectRoot = process.cwd();
|
|
491
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
492
|
+
if (fs.existsSync(path.join(projectRoot, ".git"))) {
|
|
493
|
+
let gitignore = "";
|
|
494
|
+
if (fs.existsSync(gitignorePath)) {
|
|
495
|
+
gitignore = fs.readFileSync(gitignorePath, "utf-8");
|
|
496
|
+
}
|
|
497
|
+
if (!gitignore.split("\n").some(line => line.trim() === ".gitmem/" || line.trim() === ".gitmem")) {
|
|
498
|
+
const separator = gitignore.length > 0 && !gitignore.endsWith("\n") ? "\n" : "";
|
|
499
|
+
fs.appendFileSync(gitignorePath, `${separator}\n# GitMem local data (contains credentials)\n.gitmem/\n`);
|
|
500
|
+
console.log(" ✓ Added .gitmem/ to .gitignore");
|
|
513
501
|
}
|
|
514
502
|
}
|
|
515
|
-
|
|
516
|
-
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// Non-fatal — warn but don't block activation
|
|
506
|
+
console.log(" ⚠ Could not update .gitignore — manually add .gitmem/ to prevent credential exposure");
|
|
507
|
+
}
|
|
508
|
+
// Step 7: Migrate local data to Supabase (free → pro upgrade)
|
|
509
|
+
// Handles three scenarios:
|
|
510
|
+
// A) Fresh upgrade: .json files exist → migrate and archive
|
|
511
|
+
// B) Re-activation after failed migration: .pre-migration backups exist → reimport
|
|
512
|
+
// C) Already migrated: neither exists → skip
|
|
513
|
+
if (supabaseUrl && supabaseKey && missingTables.length === 0) {
|
|
514
|
+
const hasLive = hasLocalData(gitmemDir);
|
|
515
|
+
const hasBackups = hasPreMigrationData(gitmemDir);
|
|
516
|
+
if (hasLive || hasBackups) {
|
|
517
|
+
if (hasLive) {
|
|
518
|
+
console.log("Migrating Local Data");
|
|
519
|
+
console.log(" Found existing local data from free tier...");
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
console.log("Re-importing From Backups");
|
|
523
|
+
console.log(" Found .pre-migration backup files from a previous upgrade...");
|
|
524
|
+
}
|
|
517
525
|
console.log("");
|
|
526
|
+
const migrationResult = hasLive
|
|
527
|
+
? await migrateLocalToSupabase({
|
|
528
|
+
supabaseUrl,
|
|
529
|
+
supabaseKey,
|
|
530
|
+
gitmemDir,
|
|
531
|
+
onProgress: (msg) => console.log(msg),
|
|
532
|
+
})
|
|
533
|
+
: await reimportFromBackups({
|
|
534
|
+
supabaseUrl,
|
|
535
|
+
supabaseKey,
|
|
536
|
+
gitmemDir,
|
|
537
|
+
onProgress: (msg) => console.log(msg),
|
|
538
|
+
});
|
|
539
|
+
// Report results
|
|
540
|
+
const collections = Object.keys(migrationResult.migrated);
|
|
541
|
+
let totalMigrated = 0;
|
|
542
|
+
let totalSkipped = 0;
|
|
543
|
+
let totalErrors = 0;
|
|
518
544
|
for (const col of collections) {
|
|
519
|
-
|
|
520
|
-
|
|
545
|
+
const m = migrationResult.migrated[col];
|
|
546
|
+
const s = migrationResult.skipped[col];
|
|
547
|
+
const e = migrationResult.errors[col]?.length || 0;
|
|
548
|
+
totalMigrated += m;
|
|
549
|
+
totalSkipped += s;
|
|
550
|
+
totalErrors += e;
|
|
551
|
+
if (m > 0 || s > 0) {
|
|
552
|
+
console.log(` ${m > 0 ? "✓" : "⚠"} ${col}: ${m} migrated${s > 0 ? `, ${s} failed` : ""}`);
|
|
521
553
|
}
|
|
522
554
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
555
|
+
// Show ALL errors
|
|
556
|
+
if (totalErrors > 0) {
|
|
557
|
+
console.log("");
|
|
558
|
+
for (const col of collections) {
|
|
559
|
+
for (const err of migrationResult.errors[col] || []) {
|
|
560
|
+
console.log(` ⚠ ${col}: ${err}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
531
563
|
}
|
|
564
|
+
if (totalMigrated > 0) {
|
|
565
|
+
console.log("");
|
|
566
|
+
console.log(` ✓ Migrated ${totalMigrated} records to Supabase`);
|
|
567
|
+
if (hasLive) {
|
|
568
|
+
// Archive local files so they aren't re-read
|
|
569
|
+
const archived = archiveLocalData(gitmemDir);
|
|
570
|
+
if (archived.length > 0) {
|
|
571
|
+
console.log(` ✓ Local files archived (${archived.join(", ")}.json → .pre-migration)`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else if (migrationResult.hasLocalData) {
|
|
576
|
+
console.log(" ⚠ Migration encountered errors. Local data preserved.");
|
|
577
|
+
console.log(" Check .gitmem/migration.log for details.");
|
|
578
|
+
console.log(" Fix issues and re-run: npx gitmem-mcp activate");
|
|
579
|
+
}
|
|
580
|
+
console.log("");
|
|
532
581
|
}
|
|
533
|
-
else if (migrationResult.hasLocalData) {
|
|
534
|
-
console.log(" ⚠ Migration encountered errors. Local data preserved.");
|
|
535
|
-
console.log(" Re-run activate after resolving issues.");
|
|
536
|
-
}
|
|
537
|
-
console.log("");
|
|
538
582
|
}
|
|
539
583
|
// Summary
|
|
540
584
|
console.log("");
|
|
@@ -45,6 +45,24 @@ export declare function migrateLocalToSupabase(opts: {
|
|
|
45
45
|
gitmemDir?: string;
|
|
46
46
|
onProgress?: (msg: string) => void;
|
|
47
47
|
}): Promise<MigrationResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Check if there are .pre-migration backup files that can be re-imported.
|
|
50
|
+
* This handles the case where a previous migration partially failed —
|
|
51
|
+
* the user runs `activate` again and we pick up from the backups.
|
|
52
|
+
*/
|
|
53
|
+
export declare function hasPreMigrationData(gitmemDir?: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Re-import from .pre-migration backup files.
|
|
56
|
+
* Same as migrateLocalToSupabase but reads from *.json.pre-migration files.
|
|
57
|
+
* Idempotent: uses upsert (merge-duplicates) so re-running is safe.
|
|
58
|
+
*/
|
|
59
|
+
export declare function reimportFromBackups(opts: {
|
|
60
|
+
supabaseUrl: string;
|
|
61
|
+
supabaseKey: string;
|
|
62
|
+
tablePrefix?: string;
|
|
63
|
+
gitmemDir?: string;
|
|
64
|
+
onProgress?: (msg: string) => void;
|
|
65
|
+
}): Promise<MigrationResult>;
|
|
48
66
|
/**
|
|
49
67
|
* Rename local collection files after successful migration
|
|
50
68
|
* Adds .pre-migration suffix so data isn't lost but won't be re-read by free tier
|
|
@@ -27,6 +27,40 @@ const MIGRATABLE_COLLECTIONS = ["learnings", "sessions", "decisions", "scar_usag
|
|
|
27
27
|
const STRIP_FIELDS = new Set(["is_starter"]);
|
|
28
28
|
/** Fields that Supabase will reject if null (remove instead of sending null) */
|
|
29
29
|
const NULLABLE_STRIP = new Set(["embedding"]);
|
|
30
|
+
/**
|
|
31
|
+
* Known columns per table — only these fields are sent to Supabase.
|
|
32
|
+
* PostgREST rejects POSTs with unknown columns, so we must whitelist.
|
|
33
|
+
* This prevents accumulated local fields from different code versions
|
|
34
|
+
* from causing migration failures.
|
|
35
|
+
*/
|
|
36
|
+
const KNOWN_COLUMNS = {
|
|
37
|
+
learnings: new Set([
|
|
38
|
+
"id", "learning_type", "title", "description", "severity", "scar_type",
|
|
39
|
+
"counter_arguments", "problem_context", "solution_approach", "applies_when",
|
|
40
|
+
"keywords", "domain", "embedding", "project", "source_date",
|
|
41
|
+
"source_linear_issue", "persona_name", "why_this_matters",
|
|
42
|
+
"action_protocol", "self_check_criteria", "is_active",
|
|
43
|
+
"decay_multiplier", "repeat_mistake", "related_scar_id",
|
|
44
|
+
"repeat_mistake_details", "created_at", "updated_at",
|
|
45
|
+
]),
|
|
46
|
+
sessions: new Set([
|
|
47
|
+
"id", "session_title", "session_date", "agent", "project",
|
|
48
|
+
"linear_issue", "recording_path", "transcript_path", "decisions",
|
|
49
|
+
"open_threads", "closing_reflection", "close_compliance",
|
|
50
|
+
"rapport_summary", "embedding", "created_at", "updated_at",
|
|
51
|
+
]),
|
|
52
|
+
decisions: new Set([
|
|
53
|
+
"id", "decision_date", "title", "decision", "rationale",
|
|
54
|
+
"alternatives_considered", "personas_involved", "docs_affected",
|
|
55
|
+
"linear_issue", "session_id", "project", "embedding", "created_at",
|
|
56
|
+
]),
|
|
57
|
+
scar_usage: new Set([
|
|
58
|
+
"id", "scar_id", "session_id", "agent", "issue_id",
|
|
59
|
+
"issue_identifier", "reference_type", "reference_context",
|
|
60
|
+
"surfaced_at", "acknowledged_at", "referenced",
|
|
61
|
+
"execution_successful", "variant_id", "created_at",
|
|
62
|
+
]),
|
|
63
|
+
};
|
|
30
64
|
/**
|
|
31
65
|
* Check if there is local data worth migrating
|
|
32
66
|
*/
|
|
@@ -62,23 +96,50 @@ function readLocalCollection(dir, collection) {
|
|
|
62
96
|
return [];
|
|
63
97
|
}
|
|
64
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Columns that are TEXT in Supabase but may be stored as arrays locally.
|
|
101
|
+
* During migration, arrays are joined with newlines to fit the TEXT column.
|
|
102
|
+
* This prevents PostgREST 400 errors from type mismatches.
|
|
103
|
+
*/
|
|
104
|
+
const TEXT_COERCE_FIELDS = new Set([
|
|
105
|
+
"action_protocol",
|
|
106
|
+
"self_check_criteria",
|
|
107
|
+
"why_this_matters",
|
|
108
|
+
]);
|
|
65
109
|
/**
|
|
66
110
|
* Clean a record for Supabase insertion:
|
|
67
111
|
* - Strip local-only fields
|
|
68
112
|
* - Remove null values for non-nullable columns
|
|
113
|
+
* - Only include fields that exist in the target table schema
|
|
114
|
+
* - Coerce arrays to strings for TEXT columns
|
|
69
115
|
* - Ensure id exists
|
|
70
116
|
*/
|
|
71
|
-
function cleanRecord(record) {
|
|
117
|
+
function cleanRecord(record, collection) {
|
|
72
118
|
if (!record.id)
|
|
73
119
|
return null;
|
|
120
|
+
const knownCols = KNOWN_COLUMNS[collection];
|
|
74
121
|
const cleaned = {};
|
|
122
|
+
const droppedFields = [];
|
|
75
123
|
for (const [key, value] of Object.entries(record)) {
|
|
76
124
|
if (STRIP_FIELDS.has(key))
|
|
77
125
|
continue;
|
|
78
126
|
if (NULLABLE_STRIP.has(key) && (value === null || value === undefined))
|
|
79
127
|
continue;
|
|
128
|
+
// Only include fields that exist in the target table
|
|
129
|
+
if (knownCols && !knownCols.has(key)) {
|
|
130
|
+
droppedFields.push(key);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Coerce arrays to strings for TEXT columns (schema says TEXT, code uses string[])
|
|
134
|
+
if (TEXT_COERCE_FIELDS.has(key) && Array.isArray(value)) {
|
|
135
|
+
cleaned[key] = value.join("\n");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
80
138
|
cleaned[key] = value;
|
|
81
139
|
}
|
|
140
|
+
if (droppedFields.length > 0) {
|
|
141
|
+
console.error(`[migrate] Record ${String(record.id).substring(0, 8)}: dropped unknown fields: ${droppedFields.join(", ")}`);
|
|
142
|
+
}
|
|
82
143
|
return cleaned;
|
|
83
144
|
}
|
|
84
145
|
/**
|
|
@@ -94,6 +155,14 @@ export async function migrateLocalToSupabase(opts) {
|
|
|
94
155
|
const { supabaseUrl, supabaseKey, tablePrefix = "gitmem_", onProgress } = opts;
|
|
95
156
|
const dir = opts.gitmemDir || getGitmemDir();
|
|
96
157
|
const log = onProgress || ((msg) => console.log(msg));
|
|
158
|
+
// Migration log file — captures all details for debugging
|
|
159
|
+
const logLines = [];
|
|
160
|
+
const logFile = path.join(dir, "migration.log");
|
|
161
|
+
const mlog = (msg) => {
|
|
162
|
+
const ts = new Date().toISOString();
|
|
163
|
+
logLines.push(`[${ts}] ${msg}`);
|
|
164
|
+
};
|
|
165
|
+
mlog(`Migration started: ${supabaseUrl} (prefix: ${tablePrefix})`);
|
|
97
166
|
const result = {
|
|
98
167
|
migrated: {},
|
|
99
168
|
skipped: {},
|
|
@@ -108,14 +177,21 @@ export async function migrateLocalToSupabase(opts) {
|
|
|
108
177
|
result.migrated[collection] = 0;
|
|
109
178
|
result.skipped[collection] = 0;
|
|
110
179
|
result.errors[collection] = [];
|
|
111
|
-
if (records.length === 0)
|
|
180
|
+
if (records.length === 0) {
|
|
181
|
+
mlog(`${collection}: no local data`);
|
|
112
182
|
continue;
|
|
183
|
+
}
|
|
113
184
|
result.hasLocalData = true;
|
|
185
|
+
mlog(`${collection}: ${records.length} records to migrate → ${tableName}`);
|
|
114
186
|
log(` Migrating ${records.length} ${collection}...`);
|
|
115
187
|
for (const record of records) {
|
|
116
|
-
const cleaned = cleanRecord(record);
|
|
188
|
+
const cleaned = cleanRecord(record, collection);
|
|
117
189
|
if (!cleaned) {
|
|
118
190
|
result.skipped[collection]++;
|
|
191
|
+
const errMsg = `${String(record.id || "no-id").substring(0, 8)}: skipped (missing id)`;
|
|
192
|
+
result.errors[collection].push(errMsg);
|
|
193
|
+
mlog(`${collection} SKIP: ${errMsg}`);
|
|
194
|
+
result.total++;
|
|
119
195
|
continue;
|
|
120
196
|
}
|
|
121
197
|
try {
|
|
@@ -133,25 +209,89 @@ export async function migrateLocalToSupabase(opts) {
|
|
|
133
209
|
});
|
|
134
210
|
if (response.ok) {
|
|
135
211
|
result.migrated[collection]++;
|
|
212
|
+
mlog(`${collection} OK: ${String(cleaned.id).substring(0, 8)} (${cleaned.title || ""})`);
|
|
136
213
|
}
|
|
137
214
|
else {
|
|
138
215
|
const text = await response.text();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
216
|
+
const errMsg = `${String(cleaned.id).substring(0, 8)}: ${response.status} - ${text.substring(0, 200)}`;
|
|
217
|
+
result.errors[collection].push(errMsg);
|
|
218
|
+
mlog(`${collection} FAIL: ${errMsg}`);
|
|
219
|
+
mlog(`${collection} FAIL payload: ${JSON.stringify(Object.keys(cleaned))}`);
|
|
143
220
|
result.skipped[collection]++;
|
|
144
221
|
}
|
|
145
222
|
}
|
|
146
223
|
catch (err) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
224
|
+
const errMsg = `${String(cleaned.id).substring(0, 8)}: ${err instanceof Error ? err.message : "Unknown error"}`;
|
|
225
|
+
result.errors[collection].push(errMsg);
|
|
226
|
+
mlog(`${collection} ERROR: ${errMsg}`);
|
|
150
227
|
result.skipped[collection]++;
|
|
151
228
|
}
|
|
152
229
|
result.total++;
|
|
153
230
|
}
|
|
154
231
|
}
|
|
232
|
+
// Write migration log to disk for debugging
|
|
233
|
+
mlog(`Migration complete: migrated=${JSON.stringify(result.migrated)} skipped=${JSON.stringify(result.skipped)}`);
|
|
234
|
+
try {
|
|
235
|
+
fs.writeFileSync(logFile, logLines.join("\n") + "\n", "utf-8");
|
|
236
|
+
log(` Migration log: ${logFile}`);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Non-fatal — log file write failure shouldn't block migration
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Check if there are .pre-migration backup files that can be re-imported.
|
|
245
|
+
* This handles the case where a previous migration partially failed —
|
|
246
|
+
* the user runs `activate` again and we pick up from the backups.
|
|
247
|
+
*/
|
|
248
|
+
export function hasPreMigrationData(gitmemDir) {
|
|
249
|
+
const dir = gitmemDir || getGitmemDir();
|
|
250
|
+
for (const collection of MIGRATABLE_COLLECTIONS) {
|
|
251
|
+
const backupPath = path.join(dir, `${collection}.json.pre-migration`);
|
|
252
|
+
if (fs.existsSync(backupPath)) {
|
|
253
|
+
try {
|
|
254
|
+
const data = JSON.parse(fs.readFileSync(backupPath, "utf-8"));
|
|
255
|
+
if (Array.isArray(data) && data.length > 0)
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Corrupt backup
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Re-import from .pre-migration backup files.
|
|
267
|
+
* Same as migrateLocalToSupabase but reads from *.json.pre-migration files.
|
|
268
|
+
* Idempotent: uses upsert (merge-duplicates) so re-running is safe.
|
|
269
|
+
*/
|
|
270
|
+
export async function reimportFromBackups(opts) {
|
|
271
|
+
const dir = opts.gitmemDir || getGitmemDir();
|
|
272
|
+
// Temporarily restore .pre-migration files as .json for the migration function
|
|
273
|
+
const restored = [];
|
|
274
|
+
for (const collection of MIGRATABLE_COLLECTIONS) {
|
|
275
|
+
const backupPath = path.join(dir, `${collection}.json.pre-migration`);
|
|
276
|
+
const livePath = path.join(dir, `${collection}.json`);
|
|
277
|
+
if (fs.existsSync(backupPath) && !fs.existsSync(livePath)) {
|
|
278
|
+
// Copy (not move) backup to live path for migration
|
|
279
|
+
fs.copyFileSync(backupPath, livePath);
|
|
280
|
+
restored.push(collection);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Run migration
|
|
284
|
+
const result = await migrateLocalToSupabase(opts);
|
|
285
|
+
// Clean up: remove the restored copies (backups remain untouched)
|
|
286
|
+
for (const collection of restored) {
|
|
287
|
+
const livePath = path.join(dir, `${collection}.json`);
|
|
288
|
+
try {
|
|
289
|
+
fs.unlinkSync(livePath);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Non-fatal
|
|
293
|
+
}
|
|
294
|
+
}
|
|
155
295
|
return result;
|
|
156
296
|
}
|
|
157
297
|
/**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for archive_learning tool parameters
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* Archive learning parameters schema
|
|
7
|
+
*
|
|
8
|
+
* Accepts full UUIDs or short hex prefixes (4-32 chars).
|
|
9
|
+
* Short prefixes are resolved to full UUIDs at runtime.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ArchiveLearningParamsSchema: z.ZodObject<{
|
|
12
|
+
id: z.ZodString;
|
|
13
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
id: string;
|
|
16
|
+
reason?: string | undefined;
|
|
17
|
+
}, {
|
|
18
|
+
id: string;
|
|
19
|
+
reason?: string | undefined;
|
|
20
|
+
}>;
|
|
21
|
+
export type ArchiveLearningParams = z.infer<typeof ArchiveLearningParamsSchema>;
|
|
22
|
+
//# sourceMappingURL=archive-learning.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for archive_learning tool parameters
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* Archive learning parameters schema
|
|
7
|
+
*
|
|
8
|
+
* Accepts full UUIDs or short hex prefixes (4-32 chars).
|
|
9
|
+
* Short prefixes are resolved to full UUIDs at runtime.
|
|
10
|
+
*/
|
|
11
|
+
export const ArchiveLearningParamsSchema = z.object({
|
|
12
|
+
id: z.string().min(4, "ID must be at least 4 characters (short hex prefix or full UUID)").max(36),
|
|
13
|
+
reason: z.string().max(500).optional(),
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=archive-learning.js.map
|
package/dist/schemas/index.d.ts
CHANGED
package/dist/schemas/index.js
CHANGED
package/dist/schemas/registry.js
CHANGED
|
@@ -22,6 +22,7 @@ import { PrepareContextParamsSchema } from "./prepare-context.js";
|
|
|
22
22
|
import { AbsorbObservationsParamsSchema } from "./absorb-observations.js";
|
|
23
23
|
import { ListThreadsParamsSchema, ResolveThreadParamsSchema } from "./thread.js";
|
|
24
24
|
import { ContributeFeedbackParamsSchema } from "./contribute-feedback.js";
|
|
25
|
+
import { ArchiveLearningParamsSchema } from "./archive-learning.js";
|
|
25
26
|
/**
|
|
26
27
|
* Map of canonical tool names → Zod schemas.
|
|
27
28
|
* Aliases (gitmem-r, gm-open, etc.) are resolved to canonical names before lookup.
|
|
@@ -45,6 +46,7 @@ const TOOL_SCHEMAS = {
|
|
|
45
46
|
list_threads: ListThreadsParamsSchema,
|
|
46
47
|
resolve_thread: ResolveThreadParamsSchema,
|
|
47
48
|
contribute_feedback: ContributeFeedbackParamsSchema,
|
|
49
|
+
archive_learning: ArchiveLearningParamsSchema,
|
|
48
50
|
};
|
|
49
51
|
/**
|
|
50
52
|
* Map of alias → canonical name for all tool aliases.
|
|
@@ -113,7 +115,7 @@ const ALIAS_MAP = {
|
|
|
113
115
|
// cleanup_threads — no schema yet
|
|
114
116
|
"gitmem-cleanup": "cleanup_threads",
|
|
115
117
|
"gm-cleanup": "cleanup_threads",
|
|
116
|
-
// archive_learning
|
|
118
|
+
// archive_learning
|
|
117
119
|
"gitmem-al": "archive_learning",
|
|
118
120
|
"gm-archive": "archive_learning",
|
|
119
121
|
// contribute_feedback
|
package/dist/server.js
CHANGED
|
@@ -39,6 +39,7 @@ import { contributeFeedback } from "./tools/contribute-feedback.js";
|
|
|
39
39
|
import { indexDocs } from "./tools/index-docs.js";
|
|
40
40
|
import { searchDocsHandler } from "./tools/search-docs.js";
|
|
41
41
|
import { getCacheStatus, checkCacheHealth, flushCache, startBackgroundInit, } from "./services/startup.js";
|
|
42
|
+
import { checkWritePath } from "./services/write-health.js";
|
|
42
43
|
import { getEffectTracker } from "./services/effect-tracker.js";
|
|
43
44
|
import { RIPPLE, ANSI } from "./services/display-protocol.js";
|
|
44
45
|
import { getProject } from "./services/session-state.js";
|
|
@@ -414,10 +415,18 @@ export async function runServer() {
|
|
|
414
415
|
else {
|
|
415
416
|
console.error(`[gitmem:license] Validated: ${result.tier} tier`);
|
|
416
417
|
}
|
|
418
|
+
// Verify the write path with the FINAL tier (post-validation): catches an
|
|
419
|
+
// invalid license silently downgrading writes to local files.
|
|
420
|
+
void checkWritePath();
|
|
417
421
|
}).catch((err) => {
|
|
418
422
|
console.error(`[gitmem:license] Validation error: ${err}`);
|
|
419
423
|
});
|
|
420
424
|
}
|
|
425
|
+
else {
|
|
426
|
+
// No license key to validate — verify the write path with the detected tier
|
|
427
|
+
// (catches a GITMEM_TABLE_PREFIX / schema mismatch on the backward-compat path).
|
|
428
|
+
void checkWritePath();
|
|
429
|
+
}
|
|
421
430
|
if (hasSupabase()) {
|
|
422
431
|
// Pro/Dev: Initialize local vector search in background (non-blocking)
|
|
423
432
|
// This loads scars with embeddings directly from Supabase REST API
|
|
@@ -23,7 +23,13 @@ interface EmbeddingConfig {
|
|
|
23
23
|
expectedDim: number;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Detect the best available embedding provider from environment
|
|
26
|
+
* Detect the best available embedding provider from environment.
|
|
27
|
+
*
|
|
28
|
+
* Resolution chain for OpenRouter key:
|
|
29
|
+
* 1. process.env.OPENROUTER_API_KEY (env var)
|
|
30
|
+
* 2. .gitmem/config.json → openrouter_key (written by `activate`)
|
|
31
|
+
*
|
|
32
|
+
* This matches how supabase-client.ts resolves credentials via getProConfig().
|
|
27
33
|
*/
|
|
28
34
|
export declare function detectProvider(): EmbeddingConfig;
|
|
29
35
|
/**
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Records stored without embeddings can still be retrieved by ID/filters,
|
|
15
15
|
* but won't appear in semantic search results.
|
|
16
16
|
*/
|
|
17
|
+
import { getProConfig } from "./license.js";
|
|
17
18
|
// Default embedding dimensions per provider
|
|
18
19
|
const OPENAI_EMBEDDING_DIM = 1536;
|
|
19
20
|
const OLLAMA_DEFAULT_DIM = 768;
|
|
@@ -38,10 +39,25 @@ function normalize(vec) {
|
|
|
38
39
|
return vec.map((v) => v / magnitude);
|
|
39
40
|
}
|
|
40
41
|
/**
|
|
41
|
-
* Detect the best available embedding provider from environment
|
|
42
|
+
* Detect the best available embedding provider from environment.
|
|
43
|
+
*
|
|
44
|
+
* Resolution chain for OpenRouter key:
|
|
45
|
+
* 1. process.env.OPENROUTER_API_KEY (env var)
|
|
46
|
+
* 2. .gitmem/config.json → openrouter_key (written by `activate`)
|
|
47
|
+
*
|
|
48
|
+
* This matches how supabase-client.ts resolves credentials via getProConfig().
|
|
42
49
|
*/
|
|
43
50
|
export function detectProvider() {
|
|
44
51
|
const forced = process.env.GITMEM_EMBEDDING_PROVIDER?.toLowerCase();
|
|
52
|
+
// Resolve OpenRouter key from env var OR config.json (same as supabase-client)
|
|
53
|
+
const resolveOpenRouterKey = () => {
|
|
54
|
+
if (process.env.OPENROUTER_API_KEY)
|
|
55
|
+
return process.env.OPENROUTER_API_KEY;
|
|
56
|
+
const config = getProConfig();
|
|
57
|
+
if (config.openrouterKey)
|
|
58
|
+
return config.openrouterKey;
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
45
61
|
// Forced provider
|
|
46
62
|
if (forced && forced !== "auto") {
|
|
47
63
|
switch (forced) {
|
|
@@ -60,9 +76,9 @@ export function detectProvider() {
|
|
|
60
76
|
};
|
|
61
77
|
}
|
|
62
78
|
case "openrouter": {
|
|
63
|
-
const key =
|
|
79
|
+
const key = resolveOpenRouterKey();
|
|
64
80
|
if (!key) {
|
|
65
|
-
console.warn("[embedding] GITMEM_EMBEDDING_PROVIDER=openrouter but
|
|
81
|
+
console.warn("[embedding] GITMEM_EMBEDDING_PROVIDER=openrouter but no OpenRouter key found (env or config.json)");
|
|
66
82
|
return { provider: "none", apiUrl: "", apiKey: "", model: "", expectedDim: 0 };
|
|
67
83
|
}
|
|
68
84
|
return {
|
|
@@ -98,11 +114,13 @@ export function detectProvider() {
|
|
|
98
114
|
expectedDim: OPENAI_EMBEDDING_DIM,
|
|
99
115
|
};
|
|
100
116
|
}
|
|
101
|
-
|
|
117
|
+
// Check env var AND config.json for OpenRouter key
|
|
118
|
+
const openrouterKey = resolveOpenRouterKey();
|
|
119
|
+
if (openrouterKey) {
|
|
102
120
|
return {
|
|
103
121
|
provider: "openrouter",
|
|
104
122
|
apiUrl: OPENROUTER_API_URL,
|
|
105
|
-
apiKey:
|
|
123
|
+
apiKey: openrouterKey,
|
|
106
124
|
model: OPENROUTER_EMBEDDING_MODEL,
|
|
107
125
|
expectedDim: OPENAI_EMBEDDING_DIM,
|
|
108
126
|
};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
*/
|
|
9
9
|
import { getTableName } from "./tier.js";
|
|
10
|
+
import { getProConfig } from "./license.js";
|
|
10
11
|
// OpenRouter API configuration (same as local-vector-search)
|
|
11
12
|
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/embeddings";
|
|
12
13
|
const EMBEDDING_MODEL = "openai/text-embedding-3-small";
|
|
@@ -32,9 +33,9 @@ function normalize(vec) {
|
|
|
32
33
|
* Generate embedding using OpenRouter API
|
|
33
34
|
*/
|
|
34
35
|
async function generateEmbedding(text) {
|
|
35
|
-
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
36
|
+
const apiKey = process.env.OPENROUTER_API_KEY || getProConfig().openrouterKey;
|
|
36
37
|
if (!apiKey) {
|
|
37
|
-
throw new Error("OPENROUTER_API_KEY
|
|
38
|
+
throw new Error("No OpenRouter key configured (set OPENROUTER_API_KEY env var or run: npx gitmem-mcp activate)");
|
|
38
39
|
}
|
|
39
40
|
const response = await fetch(OPENROUTER_API_URL, {
|
|
40
41
|
method: "POST",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Called fire-and-forget from create_learning — zero impact on UX latency.
|
|
18
18
|
*/
|
|
19
19
|
import * as supabase from "./supabase-client.js";
|
|
20
|
+
import { getProConfig } from "./license.js";
|
|
20
21
|
// --- Pipeline versioning ---
|
|
21
22
|
// Bump PIPELINE_VERSION when changing the prompt, model, or generation logic.
|
|
22
23
|
// Format: "gen-{major}.{minor}" — major for prompt rewrites, minor for tweaks.
|
|
@@ -83,9 +84,9 @@ function buildUserPrompt(scar) {
|
|
|
83
84
|
* Returns parsed output or null on failure.
|
|
84
85
|
*/
|
|
85
86
|
async function generateWithLLM(scar) {
|
|
86
|
-
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
87
|
+
const apiKey = process.env.OPENROUTER_API_KEY || getProConfig().openrouterKey;
|
|
87
88
|
if (!apiKey) {
|
|
88
|
-
console.error("[variant-generation] No
|
|
89
|
+
console.error("[variant-generation] No OpenRouter key (env or config.json) — falling back to deterministic");
|
|
89
90
|
return null;
|
|
90
91
|
}
|
|
91
92
|
try {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path health check.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that (a) the resolved storage tier matches the presence of Supabase
|
|
5
|
+
* credentials and (b) the tables the WRITE path resolves to actually exist on
|
|
6
|
+
* the configured backend. Surfaces two silent-failure classes loudly at
|
|
7
|
+
* startup instead of on the first failed write:
|
|
8
|
+
*
|
|
9
|
+
* 1. free_with_credentials — Supabase creds are present but the tier resolved
|
|
10
|
+
* to FREE (e.g. an invalid/expired/placeholder GITMEM_API_KEY). Writes go
|
|
11
|
+
* to local .gitmem/ files instead of Supabase, while reads (recall,
|
|
12
|
+
* cache-health) can still reach Supabase and mask the problem.
|
|
13
|
+
* 2. missing_tables — pro/dev tier, but the prefixed tables don't exist (e.g.
|
|
14
|
+
* a GITMEM_TABLE_PREFIX mismatch, or the schema was never applied).
|
|
15
|
+
* create_learning / create_decision 404 (PGRST205) on the first write.
|
|
16
|
+
*
|
|
17
|
+
* Fire-and-forget: never throws (whole body is guarded), never blocks startup.
|
|
18
|
+
* Logs a loud warning to stderr when misconfigured, a one-line confirmation
|
|
19
|
+
* otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export type WritePathMode = "local" | "free_with_credentials" | "missing_tables" | "supabase" | "skipped";
|
|
22
|
+
export interface WritePathResult {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
mode: WritePathMode;
|
|
25
|
+
missing?: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function checkWritePath(): Promise<WritePathResult>;
|
|
28
|
+
//# sourceMappingURL=write-health.d.ts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path health check.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that (a) the resolved storage tier matches the presence of Supabase
|
|
5
|
+
* credentials and (b) the tables the WRITE path resolves to actually exist on
|
|
6
|
+
* the configured backend. Surfaces two silent-failure classes loudly at
|
|
7
|
+
* startup instead of on the first failed write:
|
|
8
|
+
*
|
|
9
|
+
* 1. free_with_credentials — Supabase creds are present but the tier resolved
|
|
10
|
+
* to FREE (e.g. an invalid/expired/placeholder GITMEM_API_KEY). Writes go
|
|
11
|
+
* to local .gitmem/ files instead of Supabase, while reads (recall,
|
|
12
|
+
* cache-health) can still reach Supabase and mask the problem.
|
|
13
|
+
* 2. missing_tables — pro/dev tier, but the prefixed tables don't exist (e.g.
|
|
14
|
+
* a GITMEM_TABLE_PREFIX mismatch, or the schema was never applied).
|
|
15
|
+
* create_learning / create_decision 404 (PGRST205) on the first write.
|
|
16
|
+
*
|
|
17
|
+
* Fire-and-forget: never throws (whole body is guarded), never blocks startup.
|
|
18
|
+
* Logs a loud warning to stderr when misconfigured, a one-line confirmation
|
|
19
|
+
* otherwise.
|
|
20
|
+
*/
|
|
21
|
+
import { getTier, hasSupabase, getTablePrefix, getTableName } from "./tier.js";
|
|
22
|
+
import { isConfigured, directQuery } from "./supabase-client.js";
|
|
23
|
+
/** Error messages that indicate a resolved table is absent on the backend. */
|
|
24
|
+
const SCHEMA_MISS = /PGRST205|schema cache|does not exist|Could not find the table/i;
|
|
25
|
+
export async function checkWritePath() {
|
|
26
|
+
try {
|
|
27
|
+
// No Supabase credentials → legitimate free tier; local writes are intended.
|
|
28
|
+
if (!isConfigured()) {
|
|
29
|
+
return { ok: true, mode: "local" };
|
|
30
|
+
}
|
|
31
|
+
// Credentials present but tier resolved to free. With SUPABASE_URL set, the
|
|
32
|
+
// only path to free tier is a missing/invalid license — so create_learning /
|
|
33
|
+
// create_decision are writing to local files instead of Supabase.
|
|
34
|
+
if (!hasSupabase()) {
|
|
35
|
+
console.error("\n\u26a0\ufe0f [gitmem] WRITE PATH: Supabase is configured but the tier resolved to FREE.\n" +
|
|
36
|
+
" create_learning / create_decision are writing to local .gitmem/ files, NOT Supabase.\n" +
|
|
37
|
+
" Likely cause: a missing or invalid GITMEM_API_KEY \u2014 it must be a real gitmem_pro_... key,\n" +
|
|
38
|
+
" present in the SAME environment as this server. Check the startup 'Tier:' line and the\n" +
|
|
39
|
+
" device limit (3). Reads (recall, cache-health) can still reach Supabase, masking this.\n");
|
|
40
|
+
return { ok: false, mode: "free_with_credentials" };
|
|
41
|
+
}
|
|
42
|
+
// Pro/dev: probe the tables the write path actually resolves to. learnings
|
|
43
|
+
// and decisions hard-fail on a prefix/schema mismatch (threads fall back to
|
|
44
|
+
// local), so probing those two is sufficient and avoids column assumptions.
|
|
45
|
+
const prefix = getTablePrefix();
|
|
46
|
+
const prefixSource = process.env.GITMEM_TABLE_PREFIX ? "GITMEM_TABLE_PREFIX" : "default";
|
|
47
|
+
const missing = [];
|
|
48
|
+
for (const base of ["learnings", "decisions"]) {
|
|
49
|
+
const table = getTableName(base);
|
|
50
|
+
try {
|
|
51
|
+
await directQuery(table, { select: "id", limit: 1 });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
if (SCHEMA_MISS.test(msg))
|
|
56
|
+
missing.push(table);
|
|
57
|
+
// Transient network/auth errors are out of scope for this check.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
console.error("\n\u26a0\ufe0f [gitmem] WRITE PATH: these resolved tables do not exist on the Supabase backend:\n" +
|
|
62
|
+
` ${missing.join(", ")}\n` +
|
|
63
|
+
` Resolved table prefix: "${prefix}" (from ${prefixSource}).\n` +
|
|
64
|
+
" create_learning / create_decision WILL FAIL until this is fixed:\n" +
|
|
65
|
+
" - Pointing at an existing schema (e.g. orchestra_)? Set GITMEM_TABLE_PREFIX to match.\n" +
|
|
66
|
+
" - Fresh project? Run `npx gitmem-mcp setup` (or set DATABASE_URL and re-activate) to create tables.\n");
|
|
67
|
+
return { ok: false, mode: "missing_tables", missing };
|
|
68
|
+
}
|
|
69
|
+
console.error(`[gitmem] Write-path OK (tier ${getTier()}, prefix "${prefix}", learnings/decisions present).`);
|
|
70
|
+
return { ok: true, mode: "supabase" };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// The health check must never break startup. On any unexpected error, stay
|
|
74
|
+
// silent (don't false-alarm) rather than throw from a floated promise.
|
|
75
|
+
console.error(`[gitmem] Write-path check skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
return { ok: true, mode: "skipped" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=write-health.js.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* removed from in-memory search results.
|
|
10
10
|
*/
|
|
11
11
|
export interface ArchiveLearningParams {
|
|
12
|
-
/** UUID of the learning to archive */
|
|
12
|
+
/** UUID or short ID prefix of the learning to archive */
|
|
13
13
|
id: string;
|
|
14
14
|
/** Optional reason for archiving */
|
|
15
15
|
reason?: string;
|
|
@@ -8,16 +8,64 @@
|
|
|
8
8
|
* Also triggers a local cache flush so the archived scar is immediately
|
|
9
9
|
* removed from in-memory search results.
|
|
10
10
|
*/
|
|
11
|
-
import { directPatch, isConfigured } from "../services/supabase-client.js";
|
|
11
|
+
import { directPatch, directQuery, isConfigured } from "../services/supabase-client.js";
|
|
12
12
|
import { hasSupabase, getTableName } from "../services/tier.js";
|
|
13
13
|
import { getStorage } from "../services/storage.js";
|
|
14
14
|
import { flushCache } from "../services/startup.js";
|
|
15
15
|
import { Timer } from "../services/metrics.js";
|
|
16
16
|
import { wrapDisplay } from "../services/display-protocol.js";
|
|
17
|
+
const FULL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const HEX_PREFIX_RE = /^[0-9a-f]{4,32}$/i;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a short hex ID prefix to a full UUID.
|
|
21
|
+
* Git-style prefix resolution: accepts 4-32 char hex prefixes.
|
|
22
|
+
* Full UUIDs pass through unchanged.
|
|
23
|
+
*/
|
|
24
|
+
async function resolveIdPrefix(input) {
|
|
25
|
+
// Full UUID — pass through
|
|
26
|
+
if (FULL_UUID_RE.test(input)) {
|
|
27
|
+
return { id: input };
|
|
28
|
+
}
|
|
29
|
+
// Validate hex prefix format
|
|
30
|
+
if (!HEX_PREFIX_RE.test(input)) {
|
|
31
|
+
return { error: `Invalid ID format: "${input}". Provide a full UUID or a 4-32 char hex prefix.` };
|
|
32
|
+
}
|
|
33
|
+
const prefix = input.toLowerCase();
|
|
34
|
+
if (hasSupabase() && isConfigured()) {
|
|
35
|
+
// Supabase: use PostgREST like filter
|
|
36
|
+
const matches = await directQuery(getTableName("learnings"), {
|
|
37
|
+
select: "id",
|
|
38
|
+
filters: { id: `like.${prefix}%` },
|
|
39
|
+
limit: 2,
|
|
40
|
+
});
|
|
41
|
+
if (matches.length === 0) {
|
|
42
|
+
return { error: `No learning found with ID prefix "${prefix}"` };
|
|
43
|
+
}
|
|
44
|
+
if (matches.length > 1) {
|
|
45
|
+
const ids = matches.map(m => m.id.slice(0, 12) + "…").join(", ");
|
|
46
|
+
return { error: `Ambiguous prefix "${prefix}" — matches multiple learnings: ${ids}` };
|
|
47
|
+
}
|
|
48
|
+
return { id: matches[0].id };
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Local storage: scan and filter
|
|
52
|
+
const storage = getStorage();
|
|
53
|
+
const all = await storage.query("learnings", {});
|
|
54
|
+
const matches = all.filter(r => r.id.toLowerCase().startsWith(prefix));
|
|
55
|
+
if (matches.length === 0) {
|
|
56
|
+
return { error: `No learning found with ID prefix "${prefix}"` };
|
|
57
|
+
}
|
|
58
|
+
if (matches.length > 1) {
|
|
59
|
+
const ids = matches.map(m => m.id.slice(0, 12) + "…").join(", ");
|
|
60
|
+
return { error: `Ambiguous prefix "${prefix}" — matches multiple learnings: ${ids}` };
|
|
61
|
+
}
|
|
62
|
+
return { id: matches[0].id };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
17
65
|
export async function archiveLearning(params) {
|
|
18
66
|
const timer = new Timer();
|
|
19
67
|
if (!params.id || typeof params.id !== "string") {
|
|
20
|
-
const msg = "Missing required parameter: id (UUID of the learning to archive)";
|
|
68
|
+
const msg = "Missing required parameter: id (UUID or short ID prefix of the learning to archive)";
|
|
21
69
|
return {
|
|
22
70
|
success: false,
|
|
23
71
|
id: "",
|
|
@@ -27,12 +75,25 @@ export async function archiveLearning(params) {
|
|
|
27
75
|
performance_ms: timer.stop(),
|
|
28
76
|
};
|
|
29
77
|
}
|
|
78
|
+
// Resolve short prefix to full UUID
|
|
79
|
+
const resolved = await resolveIdPrefix(params.id);
|
|
80
|
+
if ("error" in resolved) {
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
id: params.id,
|
|
84
|
+
cache_flushed: false,
|
|
85
|
+
display: wrapDisplay(resolved.error),
|
|
86
|
+
error: resolved.error,
|
|
87
|
+
performance_ms: timer.stop(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const resolvedId = resolved.id;
|
|
30
91
|
try {
|
|
31
92
|
const archivedAt = new Date().toISOString();
|
|
32
93
|
let cacheFlushed = false;
|
|
33
94
|
if (hasSupabase() && isConfigured()) {
|
|
34
95
|
// Pro/dev: patch in Supabase
|
|
35
|
-
await directPatch(getTableName("learnings"), { id: `eq.${
|
|
96
|
+
await directPatch(getTableName("learnings"), { id: `eq.${resolvedId}` }, {
|
|
36
97
|
is_active: false,
|
|
37
98
|
archived_at: archivedAt,
|
|
38
99
|
});
|
|
@@ -47,12 +108,12 @@ export async function archiveLearning(params) {
|
|
|
47
108
|
else {
|
|
48
109
|
// Free tier: update in local JSON
|
|
49
110
|
const storage = getStorage();
|
|
50
|
-
const existing = await storage.get("learnings",
|
|
111
|
+
const existing = await storage.get("learnings", resolvedId);
|
|
51
112
|
if (!existing) {
|
|
52
|
-
const msg = `Learning ${
|
|
113
|
+
const msg = `Learning ${resolvedId} not found in local storage`;
|
|
53
114
|
return {
|
|
54
115
|
success: false,
|
|
55
|
-
id:
|
|
116
|
+
id: resolvedId,
|
|
56
117
|
cache_flushed: false,
|
|
57
118
|
display: wrapDisplay(msg),
|
|
58
119
|
error: msg,
|
|
@@ -61,17 +122,21 @@ export async function archiveLearning(params) {
|
|
|
61
122
|
}
|
|
62
123
|
await storage.upsert("learnings", {
|
|
63
124
|
...existing,
|
|
64
|
-
id:
|
|
125
|
+
id: resolvedId,
|
|
65
126
|
is_active: false,
|
|
66
127
|
archived_at: archivedAt,
|
|
67
128
|
});
|
|
68
129
|
}
|
|
69
130
|
const latencyMs = timer.stop();
|
|
70
131
|
const reasonText = params.reason ? ` Reason: ${params.reason}` : "";
|
|
71
|
-
|
|
132
|
+
// Show resolution when input differed from resolved ID
|
|
133
|
+
const idDisplay = params.id !== resolvedId
|
|
134
|
+
? `${params.id} → ${resolvedId}`
|
|
135
|
+
: resolvedId;
|
|
136
|
+
const display = `Archived learning ${idDisplay}.${reasonText}\n(${latencyMs}ms)`;
|
|
72
137
|
return {
|
|
73
138
|
success: true,
|
|
74
|
-
id:
|
|
139
|
+
id: resolvedId,
|
|
75
140
|
archived_at: archivedAt,
|
|
76
141
|
reason: params.reason,
|
|
77
142
|
cache_flushed: cacheFlushed,
|
|
@@ -84,7 +149,7 @@ export async function archiveLearning(params) {
|
|
|
84
149
|
const latencyMs = timer.stop();
|
|
85
150
|
return {
|
|
86
151
|
success: false,
|
|
87
|
-
id:
|
|
152
|
+
id: resolvedId,
|
|
88
153
|
cache_flushed: false,
|
|
89
154
|
display: wrapDisplay(`Failed to archive learning: ${message}`),
|
|
90
155
|
error: message,
|
|
@@ -2072,7 +2072,7 @@ export const TOOLS = [
|
|
|
2072
2072
|
properties: {
|
|
2073
2073
|
id: {
|
|
2074
2074
|
type: "string",
|
|
2075
|
-
description: "UUID of the learning to archive",
|
|
2075
|
+
description: "UUID or short ID prefix of the learning to archive (e.g., the 8-char prefix shown by recall/search)",
|
|
2076
2076
|
},
|
|
2077
2077
|
reason: {
|
|
2078
2078
|
type: "string",
|
|
@@ -2090,7 +2090,7 @@ export const TOOLS = [
|
|
|
2090
2090
|
properties: {
|
|
2091
2091
|
id: {
|
|
2092
2092
|
type: "string",
|
|
2093
|
-
description: "UUID of the learning to archive",
|
|
2093
|
+
description: "UUID or short ID prefix of the learning to archive (e.g., the 8-char prefix shown by recall/search)",
|
|
2094
2094
|
},
|
|
2095
2095
|
reason: {
|
|
2096
2096
|
type: "string",
|
|
@@ -2108,7 +2108,7 @@ export const TOOLS = [
|
|
|
2108
2108
|
properties: {
|
|
2109
2109
|
id: {
|
|
2110
2110
|
type: "string",
|
|
2111
|
-
description: "UUID of the learning to archive",
|
|
2111
|
+
description: "UUID or short ID prefix of the learning to archive (e.g., the 8-char prefix shown by recall/search)",
|
|
2112
2112
|
},
|
|
2113
2113
|
reason: {
|
|
2114
2114
|
type: "string",
|