@context-vault/core 2.15.0 → 2.17.1
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/package.json +2 -3
- package/src/capture/file-ops.js +2 -0
- package/src/capture/index.js +14 -0
- package/src/constants.js +7 -2
- package/src/core/categories.js +1 -0
- package/src/core/config.js +9 -2
- package/src/core/files.js +6 -29
- package/src/core/frontmatter.js +1 -0
- package/src/core/linking.js +161 -0
- package/src/core/migrate-dirs.js +196 -0
- package/src/core/status.js +28 -2
- package/src/core/temporal.js +146 -0
- package/src/index/db.js +178 -8
- package/src/index/index.js +113 -48
- package/src/index.js +5 -0
- package/src/retrieve/index.js +16 -136
- package/src/server/tools/context-status.js +7 -0
- package/src/server/tools/create-snapshot.js +37 -68
- package/src/server/tools/get-context.js +120 -19
- package/src/server/tools/save-context.js +29 -6
- package/src/server/tools.js +0 -2
- package/src/server/tools/submit-feedback.js +0 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@context-vault/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"author": "Felix Hellstrom",
|
|
29
29
|
"repository": {
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@anthropic-ai/sdk": "^0.78.0",
|
|
40
39
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
41
40
|
"sqlite-vec": "^0.1.0"
|
|
42
41
|
},
|
package/src/capture/file-ops.js
CHANGED
|
@@ -38,6 +38,7 @@ export function writeEntryFile(
|
|
|
38
38
|
identity_key,
|
|
39
39
|
expires_at,
|
|
40
40
|
supersedes,
|
|
41
|
+
related_to,
|
|
41
42
|
},
|
|
42
43
|
) {
|
|
43
44
|
// P5: folder is now a top-level param; also accept from meta for backward compat
|
|
@@ -64,6 +65,7 @@ export function writeEntryFile(
|
|
|
64
65
|
if (identity_key) fmFields.identity_key = identity_key;
|
|
65
66
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
66
67
|
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
68
|
+
if (related_to?.length) fmFields.related_to = related_to;
|
|
67
69
|
fmFields.tags = tags || [];
|
|
68
70
|
fmFields.source = source || "claude-code";
|
|
69
71
|
fmFields.created = created;
|
package/src/capture/index.js
CHANGED
|
@@ -27,6 +27,7 @@ export function writeEntry(
|
|
|
27
27
|
identity_key,
|
|
28
28
|
expires_at,
|
|
29
29
|
supersedes,
|
|
30
|
+
related_to,
|
|
30
31
|
source_files,
|
|
31
32
|
tier,
|
|
32
33
|
userId,
|
|
@@ -88,6 +89,7 @@ export function writeEntry(
|
|
|
88
89
|
identity_key,
|
|
89
90
|
expires_at,
|
|
90
91
|
supersedes,
|
|
92
|
+
related_to,
|
|
91
93
|
});
|
|
92
94
|
|
|
93
95
|
return {
|
|
@@ -105,6 +107,7 @@ export function writeEntry(
|
|
|
105
107
|
identity_key,
|
|
106
108
|
expires_at,
|
|
107
109
|
supersedes,
|
|
110
|
+
related_to: related_to || null,
|
|
108
111
|
source_files: source_files || null,
|
|
109
112
|
tier: tier || null,
|
|
110
113
|
userId: userId || null,
|
|
@@ -126,6 +129,9 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
126
129
|
|
|
127
130
|
const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
|
|
128
131
|
const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
|
|
132
|
+
const existingRelatedTo = existing.related_to
|
|
133
|
+
? JSON.parse(existing.related_to)
|
|
134
|
+
: fmMeta.related_to || null;
|
|
129
135
|
|
|
130
136
|
const title = updates.title !== undefined ? updates.title : existing.title;
|
|
131
137
|
const body = updates.body !== undefined ? updates.body : existing.body;
|
|
@@ -138,6 +144,8 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
138
144
|
updates.supersedes !== undefined
|
|
139
145
|
? updates.supersedes
|
|
140
146
|
: fmMeta.supersedes || null;
|
|
147
|
+
const related_to =
|
|
148
|
+
updates.related_to !== undefined ? updates.related_to : existingRelatedTo;
|
|
141
149
|
const source_files =
|
|
142
150
|
updates.source_files !== undefined
|
|
143
151
|
? updates.source_files
|
|
@@ -162,6 +170,7 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
162
170
|
if (existing.identity_key) fmFields.identity_key = existing.identity_key;
|
|
163
171
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
164
172
|
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
173
|
+
if (related_to?.length) fmFields.related_to = related_to;
|
|
165
174
|
fmFields.tags = tags;
|
|
166
175
|
fmFields.source = source || "claude-code";
|
|
167
176
|
fmFields.created = fmMeta.created || existing.created_at;
|
|
@@ -189,6 +198,7 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
189
198
|
identity_key: existing.identity_key,
|
|
190
199
|
expires_at,
|
|
191
200
|
supersedes,
|
|
201
|
+
related_to: related_to || null,
|
|
192
202
|
source_files: source_files || null,
|
|
193
203
|
userId: existing.user_id || null,
|
|
194
204
|
};
|
|
@@ -217,6 +227,10 @@ export async function captureAndIndex(ctx, data) {
|
|
|
217
227
|
}
|
|
218
228
|
}
|
|
219
229
|
}
|
|
230
|
+
// Store related_to links in DB
|
|
231
|
+
if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
|
|
232
|
+
ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
|
|
233
|
+
}
|
|
220
234
|
return entry;
|
|
221
235
|
} catch (err) {
|
|
222
236
|
// Rollback: restore previous content for entity upserts, delete for new entries
|
package/src/constants.js
CHANGED
|
@@ -14,8 +14,13 @@ export const MAX_SOURCE_LENGTH = 200;
|
|
|
14
14
|
export const MAX_IDENTITY_KEY_LENGTH = 200;
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_GROWTH_THRESHOLDS = {
|
|
17
|
-
totalEntries: { warn:
|
|
18
|
-
eventEntries: { warn:
|
|
17
|
+
totalEntries: { warn: 2000, critical: 5000 },
|
|
18
|
+
eventEntries: { warn: 1000, critical: 3000 },
|
|
19
19
|
vaultSizeBytes: { warn: 50 * 1024 * 1024, critical: 200 * 1024 * 1024 },
|
|
20
20
|
eventsWithoutTtl: { warn: 200 },
|
|
21
21
|
};
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_LIFECYCLE = {
|
|
24
|
+
event: { archiveAfterDays: 90 },
|
|
25
|
+
ephemeral: { archiveAfterDays: 30 },
|
|
26
|
+
};
|
package/src/core/categories.js
CHANGED
package/src/core/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { DEFAULT_GROWTH_THRESHOLDS } from "../constants.js";
|
|
4
|
+
import { DEFAULT_GROWTH_THRESHOLDS, DEFAULT_LIFECYCLE } from "../constants.js";
|
|
5
5
|
|
|
6
6
|
export function parseArgs(argv) {
|
|
7
7
|
const args = {};
|
|
@@ -27,7 +27,7 @@ export function resolveConfig() {
|
|
|
27
27
|
join(HOME, ".context-mcp"),
|
|
28
28
|
);
|
|
29
29
|
const config = {
|
|
30
|
-
vaultDir: join(HOME, "vault"),
|
|
30
|
+
vaultDir: join(HOME, ".vault"),
|
|
31
31
|
dataDir,
|
|
32
32
|
dbPath: join(dataDir, "vault.db"),
|
|
33
33
|
devDir: join(HOME, "dev"),
|
|
@@ -48,6 +48,7 @@ export function resolveConfig() {
|
|
|
48
48
|
maxAgeDays: 7,
|
|
49
49
|
autoConsolidate: false,
|
|
50
50
|
},
|
|
51
|
+
lifecycle: structuredClone(DEFAULT_LIFECYCLE),
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
const configPath = join(dataDir, "config.json");
|
|
@@ -62,6 +63,12 @@ export function resolveConfig() {
|
|
|
62
63
|
if (fc.dbPath) config.dbPath = fc.dbPath;
|
|
63
64
|
if (fc.devDir) config.devDir = fc.devDir;
|
|
64
65
|
if (fc.eventDecayDays != null) config.eventDecayDays = fc.eventDecayDays;
|
|
66
|
+
if (fc.growthWarningThreshold != null) {
|
|
67
|
+
config.thresholds.totalEntries = {
|
|
68
|
+
...config.thresholds.totalEntries,
|
|
69
|
+
warn: Number(fc.growthWarningThreshold),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
65
72
|
if (fc.thresholds) {
|
|
66
73
|
const t = fc.thresholds;
|
|
67
74
|
if (t.totalEntries)
|
package/src/core/files.js
CHANGED
|
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
|
|
|
37
37
|
return slug;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
insight: "insights",
|
|
42
|
-
decision: "decisions",
|
|
43
|
-
pattern: "patterns",
|
|
44
|
-
status: "statuses",
|
|
45
|
-
analysis: "analyses",
|
|
46
|
-
contact: "contacts",
|
|
47
|
-
project: "projects",
|
|
48
|
-
tool: "tools",
|
|
49
|
-
source: "sources",
|
|
50
|
-
conversation: "conversations",
|
|
51
|
-
message: "messages",
|
|
52
|
-
session: "sessions",
|
|
53
|
-
log: "logs",
|
|
54
|
-
feedback: "feedbacks",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const SINGULAR_MAP = Object.fromEntries(
|
|
58
|
-
Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
|
|
59
|
-
);
|
|
60
|
-
|
|
40
|
+
/** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
|
|
61
41
|
export function kindToDir(kind) {
|
|
62
|
-
|
|
63
|
-
return kind.endsWith("s") ? kind : kind + "s";
|
|
42
|
+
return kind;
|
|
64
43
|
}
|
|
65
44
|
|
|
45
|
+
/** Map directory name back to kind name. Directory names equal kind names (identity). */
|
|
66
46
|
export function dirToKind(dirName) {
|
|
67
|
-
|
|
68
|
-
return dirName.replace(/s$/, "");
|
|
47
|
+
return dirName;
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
/** Normalize a kind input
|
|
50
|
+
/** Normalize a kind input to its canonical form. Kind names are returned as-is. */
|
|
72
51
|
export function normalizeKind(input) {
|
|
73
|
-
|
|
74
|
-
if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
|
|
75
|
-
return input; // Unknown — use as-is (don't strip 's')
|
|
52
|
+
return input;
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
/** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
|
package/src/core/frontmatter.js
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linking.js — Pure graph traversal for related_to links.
|
|
3
|
+
*
|
|
4
|
+
* All functions accept a db handle and return data — no side effects.
|
|
5
|
+
* The calling layer (get-context handler) is responsible for I/O wiring.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a `related_to` JSON string from the DB into an array of ID strings.
|
|
10
|
+
* Returns an empty array on any parse failure or null input.
|
|
11
|
+
*
|
|
12
|
+
* @param {string|null|undefined} raw
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function parseRelatedTo(raw) {
|
|
16
|
+
if (!raw) return [];
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (!Array.isArray(parsed)) return [];
|
|
20
|
+
return parsed.filter((id) => typeof id === "string" && id.trim());
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fetch vault entries by their IDs, scoped to a user.
|
|
28
|
+
* Returns only entries that exist and are not expired or superseded.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
31
|
+
* @param {string[]} ids
|
|
32
|
+
* @param {string|null|undefined} userId
|
|
33
|
+
* @returns {object[]} Matching DB rows
|
|
34
|
+
*/
|
|
35
|
+
export function resolveLinks(db, ids, userId) {
|
|
36
|
+
if (!ids.length) return [];
|
|
37
|
+
const unique = [...new Set(ids)];
|
|
38
|
+
const placeholders = unique.map(() => "?").join(",");
|
|
39
|
+
// When userId is defined (hosted mode), scope to that user.
|
|
40
|
+
// When userId is undefined (local mode), no user scoping — all entries accessible.
|
|
41
|
+
const userClause =
|
|
42
|
+
userId !== undefined && userId !== null ? "AND user_id = ?" : "";
|
|
43
|
+
const params =
|
|
44
|
+
userId !== undefined && userId !== null ? [...unique, userId] : unique;
|
|
45
|
+
try {
|
|
46
|
+
return db
|
|
47
|
+
.prepare(
|
|
48
|
+
`SELECT * FROM vault
|
|
49
|
+
WHERE id IN (${placeholders})
|
|
50
|
+
${userClause}
|
|
51
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
52
|
+
AND superseded_by IS NULL`,
|
|
53
|
+
)
|
|
54
|
+
.all(...params);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find all entries that declare `entryId` in their `related_to` field
|
|
62
|
+
* (i.e. entries that point *to* this entry — backlinks).
|
|
63
|
+
* Scoped to the same user. Excludes expired and superseded entries.
|
|
64
|
+
*
|
|
65
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
66
|
+
* @param {string} entryId
|
|
67
|
+
* @param {string|null|undefined} userId
|
|
68
|
+
* @returns {object[]} Entries with a backlink to entryId
|
|
69
|
+
*/
|
|
70
|
+
export function resolveBacklinks(db, entryId, userId) {
|
|
71
|
+
if (!entryId) return [];
|
|
72
|
+
// When userId is defined (hosted mode), scope to that user.
|
|
73
|
+
// When userId is undefined (local mode), no user scoping — all entries accessible.
|
|
74
|
+
const userClause =
|
|
75
|
+
userId !== undefined && userId !== null ? "AND user_id = ?" : "";
|
|
76
|
+
const likePattern = `%"${entryId}"%`;
|
|
77
|
+
const params =
|
|
78
|
+
userId !== undefined && userId !== null
|
|
79
|
+
? [likePattern, userId]
|
|
80
|
+
: [likePattern];
|
|
81
|
+
try {
|
|
82
|
+
return db
|
|
83
|
+
.prepare(
|
|
84
|
+
`SELECT * FROM vault
|
|
85
|
+
WHERE related_to LIKE ?
|
|
86
|
+
${userClause}
|
|
87
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
88
|
+
AND superseded_by IS NULL`,
|
|
89
|
+
)
|
|
90
|
+
.all(...params);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* For a set of primary entry IDs, collect all forward links (entries pointed
|
|
98
|
+
* to by `related_to`) and backlinks (entries that point back to any primary).
|
|
99
|
+
*
|
|
100
|
+
* Returns a Map of id → entry row for all linked entries, excluding entries
|
|
101
|
+
* already present in `primaryIds`.
|
|
102
|
+
*
|
|
103
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
104
|
+
* @param {object[]} primaryEntries - Full entry rows (must have id + related_to fields)
|
|
105
|
+
* @param {string|null|undefined} userId
|
|
106
|
+
* @returns {{ forward: object[], backward: object[] }}
|
|
107
|
+
*/
|
|
108
|
+
export function collectLinkedEntries(db, primaryEntries, userId) {
|
|
109
|
+
const primaryIds = new Set(primaryEntries.map((e) => e.id));
|
|
110
|
+
|
|
111
|
+
// Forward: resolve all IDs from related_to fields
|
|
112
|
+
const forwardIds = [];
|
|
113
|
+
for (const entry of primaryEntries) {
|
|
114
|
+
const ids = parseRelatedTo(entry.related_to);
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
if (!primaryIds.has(id)) forwardIds.push(id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const forwardEntries = resolveLinks(db, forwardIds, userId).filter(
|
|
120
|
+
(e) => !primaryIds.has(e.id),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Backward: find all entries that link to any primary entry
|
|
124
|
+
const backwardSeen = new Set();
|
|
125
|
+
const backwardEntries = [];
|
|
126
|
+
const forwardIds2 = new Set(forwardEntries.map((e) => e.id));
|
|
127
|
+
for (const entry of primaryEntries) {
|
|
128
|
+
const backlinks = resolveBacklinks(db, entry.id, userId);
|
|
129
|
+
for (const bl of backlinks) {
|
|
130
|
+
if (!primaryIds.has(bl.id) && !backwardSeen.has(bl.id)) {
|
|
131
|
+
backwardSeen.add(bl.id);
|
|
132
|
+
backwardEntries.push(bl);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { forward: forwardEntries, backward: backwardEntries };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate a `related_to` value from user input.
|
|
142
|
+
* Must be an array of non-empty strings (ULID-like IDs).
|
|
143
|
+
* Returns an error message string if invalid, or null if valid.
|
|
144
|
+
*
|
|
145
|
+
* @param {unknown} relatedTo
|
|
146
|
+
* @returns {string|null}
|
|
147
|
+
*/
|
|
148
|
+
export function validateRelatedTo(relatedTo) {
|
|
149
|
+
if (relatedTo === undefined || relatedTo === null) return null;
|
|
150
|
+
if (!Array.isArray(relatedTo))
|
|
151
|
+
return "related_to must be an array of entry IDs";
|
|
152
|
+
for (const id of relatedTo) {
|
|
153
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
154
|
+
return "each related_to entry must be a non-empty string ID";
|
|
155
|
+
}
|
|
156
|
+
if (id.length > 32) {
|
|
157
|
+
return `related_to ID too long (max 32 chars): "${id.slice(0, 32)}..."`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-dirs.js — Rename plural vault directories to singular
|
|
3
|
+
*
|
|
4
|
+
* After context-vault >= 2.18.0, kindToDir() returns singular names.
|
|
5
|
+
* Existing vaults still have plural dirs (e.g. knowledge/decisions/).
|
|
6
|
+
* This module plans and executes the rename/merge migration.
|
|
7
|
+
*
|
|
8
|
+
* Architecture: pure planning function + I/O execution function.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
copyFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join, basename } from "node:path";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Complete plural→singular mapping for vault directory names.
|
|
24
|
+
* Covers the old PLURAL_MAP from files.js plus extended kinds seen in the wild.
|
|
25
|
+
*
|
|
26
|
+
* @type {Record<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export const PLURAL_TO_SINGULAR = {
|
|
29
|
+
// From old PLURAL_MAP in files.js
|
|
30
|
+
insights: "insight",
|
|
31
|
+
decisions: "decision",
|
|
32
|
+
patterns: "pattern",
|
|
33
|
+
statuses: "status",
|
|
34
|
+
analyses: "analysis",
|
|
35
|
+
contacts: "contact",
|
|
36
|
+
projects: "project",
|
|
37
|
+
tools: "tool",
|
|
38
|
+
sources: "source",
|
|
39
|
+
conversations: "conversation",
|
|
40
|
+
messages: "message",
|
|
41
|
+
sessions: "session",
|
|
42
|
+
logs: "log",
|
|
43
|
+
feedbacks: "feedback",
|
|
44
|
+
// Extended kinds from categories.js + observed in vaults
|
|
45
|
+
notes: "note",
|
|
46
|
+
prompts: "prompt",
|
|
47
|
+
documents: "document",
|
|
48
|
+
references: "reference",
|
|
49
|
+
tasks: "task",
|
|
50
|
+
buckets: "bucket",
|
|
51
|
+
architectures: "architecture",
|
|
52
|
+
briefs: "brief",
|
|
53
|
+
companies: "company",
|
|
54
|
+
discoveries: "discovery",
|
|
55
|
+
events: "event",
|
|
56
|
+
ideas: "idea",
|
|
57
|
+
issues: "issue",
|
|
58
|
+
agents: "agent",
|
|
59
|
+
"session-summaries": "session-summary",
|
|
60
|
+
"session-reviews": "session-review",
|
|
61
|
+
"user-prompts": "user-prompt",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Category directory names that are scanned for plural kind subdirectories.
|
|
66
|
+
*/
|
|
67
|
+
const CATEGORY_DIRS = ["knowledge", "entities", "events"];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Count .md files recursively in a directory.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dir
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
function countMdFiles(dir) {
|
|
76
|
+
let count = 0;
|
|
77
|
+
try {
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
count += countMdFiles(join(dir, entry.name));
|
|
81
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
82
|
+
count++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore unreadable dirs
|
|
87
|
+
}
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Plan migration operations: walk the vault and detect plural dirs that need renaming.
|
|
93
|
+
* Pure I/O read-only — does not modify any files.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} vaultDir - Absolute path to vault root
|
|
96
|
+
* @returns {MigrationOp[]} Array of planned operations
|
|
97
|
+
*
|
|
98
|
+
* @typedef {{ action: 'rename'|'merge', pluralDir: string, singularDir: string, pluralName: string, singularName: string, fileCount: number }} MigrationOp
|
|
99
|
+
*/
|
|
100
|
+
export function planMigration(vaultDir) {
|
|
101
|
+
const ops = [];
|
|
102
|
+
|
|
103
|
+
for (const catName of CATEGORY_DIRS) {
|
|
104
|
+
const catDir = join(vaultDir, catName);
|
|
105
|
+
if (!existsSync(catDir) || !statSync(catDir).isDirectory()) continue;
|
|
106
|
+
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(catDir, { withFileTypes: true });
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!entry.isDirectory()) continue;
|
|
116
|
+
const dirName = entry.name;
|
|
117
|
+
const singular = PLURAL_TO_SINGULAR[dirName];
|
|
118
|
+
|
|
119
|
+
// Not a known plural — skip (might already be singular or unknown kind)
|
|
120
|
+
if (!singular) continue;
|
|
121
|
+
|
|
122
|
+
const pluralDir = join(catDir, dirName);
|
|
123
|
+
const singularDir = join(catDir, singular);
|
|
124
|
+
|
|
125
|
+
// Guard: plural and singular are the same (shouldn't happen but be safe)
|
|
126
|
+
if (pluralDir === singularDir) continue;
|
|
127
|
+
|
|
128
|
+
const fileCount = countMdFiles(pluralDir);
|
|
129
|
+
const singularExists = existsSync(singularDir);
|
|
130
|
+
|
|
131
|
+
ops.push({
|
|
132
|
+
action: singularExists ? "merge" : "rename",
|
|
133
|
+
pluralDir,
|
|
134
|
+
singularDir,
|
|
135
|
+
pluralName: dirName,
|
|
136
|
+
singularName: singular,
|
|
137
|
+
fileCount,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return ops;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Copy all files and subdirectories from src into dst (non-overwriting).
|
|
147
|
+
* Mirrors `cp -rn src/* dst/`.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} src
|
|
150
|
+
* @param {string} dst
|
|
151
|
+
*/
|
|
152
|
+
function mergeDir(src, dst) {
|
|
153
|
+
mkdirSync(dst, { recursive: true });
|
|
154
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
155
|
+
const srcPath = join(src, entry.name);
|
|
156
|
+
const dstPath = join(dst, entry.name);
|
|
157
|
+
if (entry.isDirectory()) {
|
|
158
|
+
mergeDir(srcPath, dstPath);
|
|
159
|
+
} else if (entry.isFile()) {
|
|
160
|
+
if (!existsSync(dstPath)) {
|
|
161
|
+
copyFileSync(srcPath, dstPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Execute migration operations. Renames or merges plural dirs into singular dirs.
|
|
169
|
+
* Safe to call multiple times — already-renamed dirs produce no-op ops from planMigration.
|
|
170
|
+
*
|
|
171
|
+
* @param {MigrationOp[]} ops - Operations from planMigration()
|
|
172
|
+
* @returns {{ renamed: number, merged: number, errors: string[] }}
|
|
173
|
+
*/
|
|
174
|
+
export function executeMigration(ops) {
|
|
175
|
+
let renamed = 0;
|
|
176
|
+
let merged = 0;
|
|
177
|
+
const errors = [];
|
|
178
|
+
|
|
179
|
+
for (const op of ops) {
|
|
180
|
+
try {
|
|
181
|
+
if (op.action === "rename") {
|
|
182
|
+
renameSync(op.pluralDir, op.singularDir);
|
|
183
|
+
renamed++;
|
|
184
|
+
} else {
|
|
185
|
+
// merge: copy files from plural into singular, then remove plural
|
|
186
|
+
mergeDir(op.pluralDir, op.singularDir);
|
|
187
|
+
rmSync(op.pluralDir, { recursive: true, force: true });
|
|
188
|
+
merged++;
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
errors.push(`${op.pluralName} → ${op.singularName}: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { renamed, merged, errors };
|
|
196
|
+
}
|
package/src/core/status.js
CHANGED
|
@@ -212,7 +212,7 @@ export function gatherVaultStatus(ctx, opts = {}) {
|
|
|
212
212
|
*
|
|
213
213
|
* @param {object} status — result of gatherVaultStatus()
|
|
214
214
|
* @param {object} thresholds — from config.thresholds
|
|
215
|
-
* @returns {{ warnings: Array, hasCritical: boolean, hasWarnings: boolean, actions: string[] }}
|
|
215
|
+
* @returns {{ warnings: Array, hasCritical: boolean, hasWarnings: boolean, actions: string[], kindBreakdown: Array }}
|
|
216
216
|
*/
|
|
217
217
|
export function computeGrowthWarnings(status, thresholds) {
|
|
218
218
|
if (!thresholds)
|
|
@@ -221,6 +221,7 @@ export function computeGrowthWarnings(status, thresholds) {
|
|
|
221
221
|
hasCritical: false,
|
|
222
222
|
hasWarnings: false,
|
|
223
223
|
actions: [],
|
|
224
|
+
kindBreakdown: [],
|
|
224
225
|
};
|
|
225
226
|
|
|
226
227
|
const t = thresholds;
|
|
@@ -235,12 +236,16 @@ export function computeGrowthWarnings(status, thresholds) {
|
|
|
235
236
|
dbSizeBytes = 0,
|
|
236
237
|
} = status;
|
|
237
238
|
|
|
239
|
+
let totalExceeded = false;
|
|
240
|
+
|
|
238
241
|
if (t.totalEntries?.critical != null && total >= t.totalEntries.critical) {
|
|
242
|
+
totalExceeded = true;
|
|
239
243
|
warnings.push({
|
|
240
244
|
level: "critical",
|
|
241
245
|
message: `Total entries: ${total.toLocaleString()} (exceeds critical limit of ${t.totalEntries.critical.toLocaleString()})`,
|
|
242
246
|
});
|
|
243
247
|
} else if (t.totalEntries?.warn != null && total >= t.totalEntries.warn) {
|
|
248
|
+
totalExceeded = true;
|
|
244
249
|
warnings.push({
|
|
245
250
|
level: "warn",
|
|
246
251
|
message: `Total entries: ${total.toLocaleString()} (exceeds recommended ${t.totalEntries.warn.toLocaleString()})`,
|
|
@@ -320,5 +325,26 @@ export function computeGrowthWarnings(status, thresholds) {
|
|
|
320
325
|
actions.push("Consider archiving events older than 90 days");
|
|
321
326
|
}
|
|
322
327
|
|
|
323
|
-
|
|
328
|
+
const kindBreakdown = totalExceeded
|
|
329
|
+
? buildKindBreakdown(status.kindCounts, total)
|
|
330
|
+
: [];
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
warnings,
|
|
334
|
+
hasCritical,
|
|
335
|
+
hasWarnings: warnings.length > 0,
|
|
336
|
+
actions,
|
|
337
|
+
kindBreakdown,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildKindBreakdown(kindCounts, total) {
|
|
342
|
+
if (!kindCounts?.length || total === 0) return [];
|
|
343
|
+
return [...kindCounts]
|
|
344
|
+
.sort((a, b) => b.c - a.c)
|
|
345
|
+
.map(({ kind, c }) => ({
|
|
346
|
+
kind,
|
|
347
|
+
count: c,
|
|
348
|
+
pct: Math.round((c / total) * 100),
|
|
349
|
+
}));
|
|
324
350
|
}
|