om-memory-system 4.8.0 → 4.10.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/dist/cli/web-command.js +11 -2
- package/dist/cli/web-power.d.ts +21 -3
- package/dist/cli/web-power.js +99 -15
- package/dist/config.d.ts +5 -1
- package/dist/config.js +7 -1
- package/dist/importer/import-path-maps.d.ts +5 -0
- package/dist/importer/import-path-maps.js +20 -0
- package/dist/importer/map-suggestions.d.ts +45 -10
- package/dist/importer/map-suggestions.js +282 -32
- package/dist/importer/web-import-api.d.ts +1 -1
- package/dist/importer/web-import-api.js +1 -1
- package/dist/services/backfill-state.d.ts +10 -0
- package/dist/services/backfill-state.js +19 -1
- package/dist/services/global-config-writer.js +2 -1
- package/dist/services/settings-snapshot.js +2 -0
- package/dist/services/web-server.d.ts +10 -1
- package/dist/services/web-server.js +112 -10
- package/dist/services/web-update-runtime.d.ts +3 -0
- package/dist/services/web-update-runtime.js +47 -0
- package/dist/services/web-update.d.ts +76 -0
- package/dist/services/web-update.js +157 -0
- package/dist/web/assets/index-BkqDHQCc.css +2 -0
- package/dist/web/assets/index-DWPF5lXj.js +125 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-bvbynkv3.js +0 -125
- package/dist/web/assets/index-kKl1G5gD.css +0 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { basename, dirname, join, parse } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, parse, resolve, sep } from "node:path";
|
|
4
4
|
const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
|
|
5
5
|
function isDirectory(path) {
|
|
6
6
|
try {
|
|
@@ -10,8 +10,45 @@ function isDirectory(path) {
|
|
|
10
10
|
return false;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
function isProject(path) {
|
|
14
|
-
|
|
13
|
+
function isProject(path, projects) {
|
|
14
|
+
const cached = projects?.get(path);
|
|
15
|
+
if (cached !== undefined)
|
|
16
|
+
return cached;
|
|
17
|
+
const found = PROJECT_MARKERS.some((marker) => existsSync(join(path, marker)));
|
|
18
|
+
projects?.set(path, found);
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
/** Windows paths can mix `\` and `/`, so both count as a separator here. */
|
|
22
|
+
function isInside(path, folder) {
|
|
23
|
+
const slashes = (value) => value.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
24
|
+
const inner = slashes(path);
|
|
25
|
+
const outer = slashes(folder);
|
|
26
|
+
return inner === outer || inner.startsWith(`${outer}/`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The main working tree of a linked Git worktree, from the `gitdir:` line of
|
|
30
|
+
* its `.git` file; the folder itself otherwise. Null when a linked worktree's
|
|
31
|
+
* main tree is gone. Reads one file and starts no `git` process.
|
|
32
|
+
*/
|
|
33
|
+
function projectRoot(path) {
|
|
34
|
+
let gitFile;
|
|
35
|
+
try {
|
|
36
|
+
if (!statSync(join(path, ".git")).isFile())
|
|
37
|
+
return path;
|
|
38
|
+
gitFile = readFileSync(join(path, ".git"), "utf8");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
const gitdir = /^gitdir:\s*(.+)$/m.exec(gitFile)?.[1]?.trim();
|
|
44
|
+
if (!gitdir)
|
|
45
|
+
return path;
|
|
46
|
+
const marker = `${sep}.git${sep}worktrees${sep}`;
|
|
47
|
+
const at = resolve(path, gitdir).indexOf(marker);
|
|
48
|
+
if (at < 0)
|
|
49
|
+
return path;
|
|
50
|
+
const main = resolve(path, gitdir).slice(0, at);
|
|
51
|
+
return isDirectory(main) && isProject(main) ? main : null;
|
|
15
52
|
}
|
|
16
53
|
function childDirectories(parent, cache) {
|
|
17
54
|
const cached = cache?.get(parent);
|
|
@@ -33,23 +70,112 @@ function childDirectories(parent, cache) {
|
|
|
33
70
|
function isLeadingPart(candidate, name) {
|
|
34
71
|
return name === candidate || name.startsWith(`${candidate}-`);
|
|
35
72
|
}
|
|
73
|
+
function nameParts(name) {
|
|
74
|
+
return name
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.split(/[-_.]+/)
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
36
79
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* project worktree. Returns null when there is no candidate.
|
|
80
|
+
* True when every part of `candidate` matches, in order, one part of
|
|
81
|
+
* `missing` or the initials of two or more consecutive parts, and at least two
|
|
82
|
+
* parts match exactly: `om-pi-subagents` for `opinionated-modular-pi-subagents-system`.
|
|
41
83
|
*/
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
84
|
+
function isRenameOf(candidate, missing) {
|
|
85
|
+
const want = nameParts(missing);
|
|
86
|
+
let next = 0;
|
|
87
|
+
let exact = 0;
|
|
88
|
+
for (const part of nameParts(candidate)) {
|
|
89
|
+
let matched = false;
|
|
90
|
+
for (let at = next; at < want.length && !matched; at++) {
|
|
91
|
+
if (want[at] === part) {
|
|
92
|
+
exact++;
|
|
93
|
+
next = at + 1;
|
|
94
|
+
matched = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (let end = at + 2; end <= want.length; end++) {
|
|
98
|
+
const initials = want
|
|
99
|
+
.slice(at, end)
|
|
100
|
+
.map((word) => word[0])
|
|
101
|
+
.join("");
|
|
102
|
+
if (initials !== part)
|
|
103
|
+
continue;
|
|
104
|
+
next = end;
|
|
105
|
+
matched = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!matched)
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return exact >= 2;
|
|
113
|
+
}
|
|
114
|
+
/** Folders that never hold a project worth mapping. Pure path checks. */
|
|
115
|
+
function notProjectReason(missing, home, claudeFolder = join(home, ".claude")) {
|
|
116
|
+
if (missing.split(/[\\/]/).includes("node_modules"))
|
|
117
|
+
return "node_modules";
|
|
118
|
+
if (isInside(missing, join(home, "Library", "Application Support")))
|
|
119
|
+
return "app-data";
|
|
120
|
+
const skills = [join(home, ".agents", "skills"), join(claudeFolder, "skills")];
|
|
121
|
+
if (skills.some((folder) => isInside(missing, folder)))
|
|
122
|
+
return "skills";
|
|
123
|
+
const temporary = ["/tmp", "/private/tmp", "/private/var/folders", tmpdir()];
|
|
124
|
+
if (!temporary.some((folder) => isInside(missing, folder)))
|
|
125
|
+
return null;
|
|
126
|
+
// A test home sits in a temporary folder; its projects are not temporary.
|
|
127
|
+
// Windows is the other way round: its temporary folder sits in the home folder.
|
|
128
|
+
const homeIsTemporary = temporary.some((folder) => isInside(home, folder));
|
|
129
|
+
return homeIsTemporary && isInside(missing, home) ? null : "temporary";
|
|
130
|
+
}
|
|
131
|
+
/** The one distinct target in `targets`, after resolving linked worktrees; null otherwise. */
|
|
132
|
+
function onlyTarget(targets, missing) {
|
|
133
|
+
const found = new Set();
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
if (target === missing || !isDirectory(target))
|
|
46
136
|
continue;
|
|
47
|
-
|
|
137
|
+
const root = projectRoot(target);
|
|
138
|
+
if (root)
|
|
139
|
+
found.add(root);
|
|
48
140
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
141
|
+
return found.size === 1 ? [...found][0] : null;
|
|
142
|
+
}
|
|
143
|
+
/** Rule 2: the one existing project with the git remote the store recorded for `missing`. */
|
|
144
|
+
function sameRemote(recorded, known) {
|
|
145
|
+
// Store paths are resolved; a recorded path may carry a trailing separator.
|
|
146
|
+
const missing = resolve(recorded);
|
|
147
|
+
const remotes = new Set(known
|
|
148
|
+
.filter((project) => project.path === missing || project.candidates?.includes(missing))
|
|
149
|
+
.map((project) => project.remote)
|
|
150
|
+
.filter((remote) => Boolean(remote)));
|
|
151
|
+
if (!remotes.size)
|
|
152
|
+
return null;
|
|
153
|
+
const matches = known.filter((project) => project.remote && remotes.has(project.remote));
|
|
154
|
+
return onlyTarget(matches.flatMap((project) => [
|
|
155
|
+
project.path,
|
|
156
|
+
// An older recorded folder may now hold another project, so it must still have the remote.
|
|
157
|
+
...(project.candidates ?? []).filter((path) => {
|
|
158
|
+
const root = isDirectory(path) ? projectRoot(path) : null;
|
|
159
|
+
return root !== null && originUrl(root) === project.remote;
|
|
160
|
+
}),
|
|
161
|
+
]), missing);
|
|
162
|
+
}
|
|
163
|
+
/** `remote.origin.url` from a repository's `.git/config`, read without starting `git`. */
|
|
164
|
+
function originUrl(root) {
|
|
165
|
+
try {
|
|
166
|
+
const config = readFileSync(join(root, ".git", "config"), "utf8");
|
|
167
|
+
const origin = /\[remote "origin"\]([^[]*)/.exec(config)?.[1] ?? "";
|
|
168
|
+
return /^\s*url\s*=\s*(.+?)\s*$/m.exec(origin)?.[1] ?? null;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Rule 4: the main repository of a deleted worktree, by the longest leading
|
|
176
|
+
* part of the directory's name or one of its parents' names.
|
|
177
|
+
*/
|
|
178
|
+
function deletedWorktree(missing, searchRoots, blocked, home, cache, projects) {
|
|
53
179
|
let ancestor = missing;
|
|
54
180
|
while (dirname(ancestor) !== ancestor && ancestor !== home) {
|
|
55
181
|
const name = basename(ancestor);
|
|
@@ -58,26 +184,118 @@ export function suggestMapTarget(missing, context = {}) {
|
|
|
58
184
|
for (const parent of parents) {
|
|
59
185
|
if (blocked.has(parent))
|
|
60
186
|
continue;
|
|
61
|
-
for (const candidate of childDirectories(parent,
|
|
187
|
+
for (const candidate of childDirectories(parent, cache)) {
|
|
62
188
|
if (candidate === ancestor || !isLeadingPart(basename(candidate), name))
|
|
63
189
|
continue;
|
|
64
|
-
if (!isProject(candidate))
|
|
190
|
+
if (!isProject(candidate, projects))
|
|
191
|
+
continue;
|
|
192
|
+
const root = projectRoot(candidate);
|
|
193
|
+
if (!root)
|
|
65
194
|
continue;
|
|
66
|
-
if (!best || basename(candidate).length >
|
|
67
|
-
best = candidate;
|
|
195
|
+
if (!best || basename(candidate).length > best.name.length) {
|
|
196
|
+
best = { name: basename(candidate), root };
|
|
197
|
+
}
|
|
68
198
|
}
|
|
69
199
|
}
|
|
70
200
|
if (best)
|
|
71
|
-
return best;
|
|
201
|
+
return best.root;
|
|
72
202
|
ancestor = dirname(ancestor);
|
|
73
203
|
}
|
|
74
|
-
const worktree = context.opencodeWorktree?.(missing);
|
|
75
|
-
if (worktree && worktree !== "/" && isDirectory(worktree))
|
|
76
|
-
return worktree;
|
|
77
204
|
return null;
|
|
78
205
|
}
|
|
206
|
+
/** Rule 6: the one project beside `missing` or beside a known project whose name is a rename of it. */
|
|
207
|
+
function renameGuess(missing, searchRoots, blocked, cache, projects) {
|
|
208
|
+
const name = basename(missing);
|
|
209
|
+
const candidates = [];
|
|
210
|
+
for (const parent of new Set([dirname(missing), ...searchRoots])) {
|
|
211
|
+
if (blocked.has(parent))
|
|
212
|
+
continue;
|
|
213
|
+
for (const candidate of childDirectories(parent, cache)) {
|
|
214
|
+
if (isRenameOf(basename(candidate), name) && isProject(candidate, projects)) {
|
|
215
|
+
candidates.push(candidate);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return onlyTarget(candidates, missing);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The parent folders of existing known projects, searched for siblings. The
|
|
223
|
+
* home folder and folders that are not projects (temporary, `node_modules`,
|
|
224
|
+
* app data, skills) are left out: their other children are rarely related.
|
|
225
|
+
*/
|
|
226
|
+
function searchRootsOf(known, home, claudeFolder) {
|
|
227
|
+
const roots = new Set();
|
|
228
|
+
for (const project of known) {
|
|
229
|
+
if (!isDirectory(project.path))
|
|
230
|
+
continue;
|
|
231
|
+
const parent = dirname(project.path);
|
|
232
|
+
if (parent === home || notProjectReason(parent, home, claudeFolder))
|
|
233
|
+
continue;
|
|
234
|
+
roots.add(parent);
|
|
235
|
+
}
|
|
236
|
+
return roots;
|
|
237
|
+
}
|
|
238
|
+
/** Rules 4 to 6 for one missing directory, first result wins. */
|
|
239
|
+
function suggestByName(missing, context, home) {
|
|
240
|
+
const known = context.knownProjects ?? [];
|
|
241
|
+
const searchRoots = context.searchRoots ?? searchRootsOf(known, home, context.claudeFolder);
|
|
242
|
+
// Never list the filesystem root or the folder that holds every home folder,
|
|
243
|
+
// and stop at the home folder itself: broad scans suggest unrelated folders.
|
|
244
|
+
const blocked = new Set([parse(missing).root, dirname(home)]);
|
|
245
|
+
const worktree = deletedWorktree(missing, searchRoots, blocked, home, context.cache, context.projects);
|
|
246
|
+
if (worktree)
|
|
247
|
+
return { kind: "map", target: worktree, confidence: "name" };
|
|
248
|
+
const moved = onlyTarget(known
|
|
249
|
+
.flatMap((project) => [project.path, ...(project.candidates ?? [])])
|
|
250
|
+
.filter((path) => basename(path) === basename(missing)), missing);
|
|
251
|
+
if (moved)
|
|
252
|
+
return { kind: "map", target: moved, confidence: "name" };
|
|
253
|
+
const renamed = renameGuess(missing, searchRoots, blocked, context.cache, context.projects);
|
|
254
|
+
if (renamed)
|
|
255
|
+
return { kind: "map", target: renamed, confidence: "guess" };
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Suggest what to do with a directory that no longer exists. Rules run in
|
|
260
|
+
* order and the first result wins: (1) ignore a folder that is not a project,
|
|
261
|
+
* (2) the project with the same stored git remote, (3) OpenCode's recorded
|
|
262
|
+
* project folder, or rules 2 and 4 to 6 on it when it is gone too, (4) the
|
|
263
|
+
* main repository of a deleted worktree, (5) the one known project with the
|
|
264
|
+
* same folder name, (6) a rename guess by name parts and initials. Returns
|
|
265
|
+
* null when no rule gives a result. Reads folders and `.git` files only.
|
|
266
|
+
*/
|
|
267
|
+
export function suggestMapTarget(missing, context = {}) {
|
|
268
|
+
const home = context.home ?? homedir();
|
|
269
|
+
const reason = notProjectReason(missing, home, context.claudeFolder);
|
|
270
|
+
if (reason)
|
|
271
|
+
return { kind: "ignore", reason };
|
|
272
|
+
const known = context.knownProjects ?? [];
|
|
273
|
+
const byRemote = (path) => {
|
|
274
|
+
const target = sameRemote(path, known);
|
|
275
|
+
return target ? { kind: "map", target, confidence: "exact" } : null;
|
|
276
|
+
};
|
|
277
|
+
const remote = byRemote(missing);
|
|
278
|
+
if (remote)
|
|
279
|
+
return remote;
|
|
280
|
+
const recorded = context.opencodeWorktree?.(missing);
|
|
281
|
+
if (recorded && recorded !== "/" && recorded !== missing) {
|
|
282
|
+
if (isDirectory(recorded))
|
|
283
|
+
return { kind: "map", target: recorded, confidence: "exact" };
|
|
284
|
+
// One level deep: the recorded folder is gone too.
|
|
285
|
+
const followed = byRemote(recorded) ?? suggestByName(recorded, context, home);
|
|
286
|
+
if (followed)
|
|
287
|
+
return followed;
|
|
288
|
+
}
|
|
289
|
+
return suggestByName(missing, context, home);
|
|
290
|
+
}
|
|
79
291
|
export function suggestMapTargets(directories, context = {}) {
|
|
80
|
-
const withCache = {
|
|
292
|
+
const withCache = {
|
|
293
|
+
...context,
|
|
294
|
+
cache: context.cache ?? new Map(),
|
|
295
|
+
projects: context.projects ?? new Map(),
|
|
296
|
+
searchRoots: context.searchRoots ??
|
|
297
|
+
searchRootsOf(context.knownProjects ?? [], context.home ?? homedir(), context.claudeFolder),
|
|
298
|
+
};
|
|
81
299
|
return directories.map((item) => ({
|
|
82
300
|
...item,
|
|
83
301
|
// Sessions with no recorded directory have nothing to map.
|
|
@@ -105,14 +323,42 @@ export async function readOpencodeWorktrees(dbPath) {
|
|
|
105
323
|
}
|
|
106
324
|
}
|
|
107
325
|
/**
|
|
108
|
-
* The
|
|
109
|
-
*
|
|
326
|
+
* The memory store's projects with their recorded paths and git remote, read
|
|
327
|
+
* once through the shard inventory. Empty when there is no store or the read
|
|
328
|
+
* fails, so the other sources still give suggestions. Never creates a store.
|
|
329
|
+
*/
|
|
330
|
+
export async function readStoreProjects(currentDirectory) {
|
|
331
|
+
try {
|
|
332
|
+
const { CONFIG } = await import("../config.js");
|
|
333
|
+
if (!existsSync(join(CONFIG.storagePath, "metadata.db")))
|
|
334
|
+
return [];
|
|
335
|
+
const { shardInventoryService } = await import("../services/shard-inventory-service.js");
|
|
336
|
+
const inventory = await shardInventoryService.listShards(currentDirectory);
|
|
337
|
+
return inventory.shards.flatMap((group) => {
|
|
338
|
+
const candidates = group.projectPathCandidates.map((candidate) => resolve(candidate.path));
|
|
339
|
+
const path = group.projectPath ? resolve(group.projectPath) : candidates[0];
|
|
340
|
+
return path ? [{ path, candidates, remote: group.gitRepoUrl }] : [];
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The Directory maps list: saved maps, ignored directories, and each host's
|
|
349
|
+
* unresolved directories from its latest run, with suggestions. Paths and
|
|
350
|
+
* counts only.
|
|
110
351
|
*/
|
|
111
352
|
export async function directoryMapsView(options = {}) {
|
|
112
353
|
const { CONFIG } = await import("../config.js");
|
|
113
354
|
const { readUnresolvedDirectories } = await import("../services/backfill-state.js");
|
|
114
355
|
const { DEFAULT_OPENCODE_DB } = await import("./opencode-reader.js");
|
|
356
|
+
const { resolveClaudeFolder } = await import("../services/claude-folder.js");
|
|
357
|
+
const claudeFolder = resolveClaudeFolder(CONFIG.claudeConfigDir).folder;
|
|
115
358
|
const saved = new Set(CONFIG.importPathMaps.map((map) => map.from));
|
|
359
|
+
// Older config stubs in tests have no list.
|
|
360
|
+
const ignored = CONFIG.importIgnoredDirectories ?? [];
|
|
361
|
+
const hidden = new Set([...saved, ...ignored].map((path) => resolve(path)));
|
|
116
362
|
let worktrees = new Map();
|
|
117
363
|
const dbPath = options.opencodeDbPath ?? DEFAULT_OPENCODE_DB;
|
|
118
364
|
if (existsSync(dbPath)) {
|
|
@@ -120,18 +366,22 @@ export async function directoryMapsView(options = {}) {
|
|
|
120
366
|
}
|
|
121
367
|
const knownProjects = [
|
|
122
368
|
...(options.knownProjects ?? []),
|
|
123
|
-
...CONFIG.importPathMaps.map((map) => map.to),
|
|
124
|
-
...worktrees.values(),
|
|
369
|
+
...CONFIG.importPathMaps.map((map) => ({ path: map.to })),
|
|
370
|
+
...[...worktrees.values()].map((path) => ({ path })),
|
|
125
371
|
];
|
|
126
372
|
const host = async (name) => {
|
|
127
|
-
const pending = (await readUnresolvedDirectories(name)).filter(
|
|
373
|
+
const pending = (await readUnresolvedDirectories(name)).filter(
|
|
374
|
+
// Config paths are resolved; a recorded path may carry a trailing separator.
|
|
375
|
+
(item) => !item.directory || !hidden.has(resolve(item.directory)));
|
|
128
376
|
return suggestMapTargets(pending, {
|
|
129
377
|
knownProjects,
|
|
378
|
+
claudeFolder,
|
|
130
379
|
...(name === "opencode" ? { opencodeWorktree: (dir) => worktrees.get(dir) } : {}),
|
|
131
380
|
});
|
|
132
381
|
};
|
|
133
382
|
return {
|
|
134
383
|
saved: CONFIG.importPathMaps,
|
|
384
|
+
ignored,
|
|
135
385
|
pi: await host("pi"),
|
|
136
386
|
opencode: await host("opencode"),
|
|
137
387
|
"claude-code": await host("claude-code"),
|
|
@@ -8,6 +8,6 @@ export { listImportSessions, validateSessionListRequest } from "./import-session
|
|
|
8
8
|
export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
|
|
9
9
|
export { stopStandaloneOpencodeReads } from "./opencode-standalone-models.js";
|
|
10
10
|
export { BackfillControls } from "./backfill-controls.js";
|
|
11
|
-
export { directoryMapsView } from "./map-suggestions.js";
|
|
11
|
+
export { directoryMapsView, readStoreProjects, type KnownProject } from "./map-suggestions.js";
|
|
12
12
|
export { testExternalApi } from "./external-api-test.js";
|
|
13
13
|
export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
|
|
@@ -8,6 +8,6 @@ export { listImportSessions, validateSessionListRequest } from "./import-session
|
|
|
8
8
|
export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
|
|
9
9
|
export { stopStandaloneOpencodeReads } from "./opencode-standalone-models.js";
|
|
10
10
|
export { BackfillControls } from "./backfill-controls.js";
|
|
11
|
-
export { directoryMapsView } from "./map-suggestions.js";
|
|
11
|
+
export { directoryMapsView, readStoreProjects } from "./map-suggestions.js";
|
|
12
12
|
export { testExternalApi } from "./external-api-test.js";
|
|
13
13
|
export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
|
|
@@ -51,6 +51,16 @@ export declare function unresolvedSessionCount(report: {
|
|
|
51
51
|
}>;
|
|
52
52
|
unresolvableSessions: ReadonlyArray<unknown>;
|
|
53
53
|
}): number;
|
|
54
|
+
/**
|
|
55
|
+
* A host's unresolved session count without the sessions in ignored
|
|
56
|
+
* directories. It comes from the saved directory list, the same list the
|
|
57
|
+
* Directory maps page shows, because a later listing or CLI run can replace
|
|
58
|
+
* that list after the run that set `count`. With no list, or a list cut at
|
|
59
|
+
* {@link MAX_UNRESOLVED_DIRECTORIES}, it subtracts from `count` instead and
|
|
60
|
+
* stops at 0. Sessions with no recorded directory always count. Paths are
|
|
61
|
+
* compared in resolved form.
|
|
62
|
+
*/
|
|
63
|
+
export declare function visibleUnresolvedCount(count: number, directories: readonly UnresolvedDirectory[], ignored: readonly string[]): number;
|
|
54
64
|
/**
|
|
55
65
|
* Save the unresolved directories of a host's latest full run or listing. They
|
|
56
66
|
* live in their own table, so recording them never sets a backfill cutoff.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { CONFIG } from "../config.js";
|
|
4
4
|
import { tursoConnectionManager } from "./turso/connection-manager.js";
|
|
5
5
|
import { safeHealthError } from "./safe-health-error.js";
|
|
@@ -79,6 +79,24 @@ export function unresolvedSessionCount(report) {
|
|
|
79
79
|
const mapped = (report.unresolvedProjects ?? []).reduce((sum, item) => sum + item.sessions, 0);
|
|
80
80
|
return mapped + report.unresolvableSessions.length;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* A host's unresolved session count without the sessions in ignored
|
|
84
|
+
* directories. It comes from the saved directory list, the same list the
|
|
85
|
+
* Directory maps page shows, because a later listing or CLI run can replace
|
|
86
|
+
* that list after the run that set `count`. With no list, or a list cut at
|
|
87
|
+
* {@link MAX_UNRESOLVED_DIRECTORIES}, it subtracts from `count` instead and
|
|
88
|
+
* stops at 0. Sessions with no recorded directory always count. Paths are
|
|
89
|
+
* compared in resolved form.
|
|
90
|
+
*/
|
|
91
|
+
export function visibleUnresolvedCount(count, directories, ignored) {
|
|
92
|
+
const skip = new Set(ignored.map((path) => resolve(path)));
|
|
93
|
+
const isIgnored = (item) => item.directory !== NO_DIRECTORY && skip.has(resolve(item.directory));
|
|
94
|
+
const sum = (items) => items.reduce((total, item) => total + item.sessions, 0);
|
|
95
|
+
if (directories.length > 0 && directories.length < MAX_UNRESOLVED_DIRECTORIES) {
|
|
96
|
+
return sum(directories.filter((item) => !isIgnored(item)));
|
|
97
|
+
}
|
|
98
|
+
return Math.max(0, count - sum(directories.filter(isIgnored)));
|
|
99
|
+
}
|
|
82
100
|
/**
|
|
83
101
|
* Save the unresolved directories of a host's latest full run or listing. They
|
|
84
102
|
* live in their own table, so recording them never sets a backfill cutoff.
|
|
@@ -23,6 +23,7 @@ const keys = new Set([
|
|
|
23
23
|
"memoryModel",
|
|
24
24
|
"memoryApiKey",
|
|
25
25
|
"importPathMaps",
|
|
26
|
+
"importIgnoredDirectories",
|
|
26
27
|
"claudeConfigDir",
|
|
27
28
|
"maxMemories",
|
|
28
29
|
"chatMessage.maxMemories",
|
|
@@ -81,7 +82,7 @@ function isValidEdit(key, value) {
|
|
|
81
82
|
if (key === "memoryProvider")
|
|
82
83
|
return MEMORY_PROVIDERS.includes(value);
|
|
83
84
|
// Entries are checked by the startup validation below.
|
|
84
|
-
if (key === "importPathMaps")
|
|
85
|
+
if (key === "importPathMaps" || key === "importIgnoredDirectories")
|
|
85
86
|
return Array.isArray(value);
|
|
86
87
|
// The five memory limits share one validation rule set.
|
|
87
88
|
const memoryLimit = getMemoryLimitRule(key);
|
|
@@ -24,6 +24,7 @@ const editable = [
|
|
|
24
24
|
"memoryApiUrl",
|
|
25
25
|
"memoryModel",
|
|
26
26
|
"importPathMaps",
|
|
27
|
+
"importIgnoredDirectories",
|
|
27
28
|
"claudeConfigDir",
|
|
28
29
|
...MEMORY_LIMIT_SETTINGS,
|
|
29
30
|
];
|
|
@@ -37,6 +38,7 @@ const globalOnly = [
|
|
|
37
38
|
"memoryProvider",
|
|
38
39
|
"memoryApiUrl",
|
|
39
40
|
"importPathMaps",
|
|
41
|
+
"importIgnoredDirectories",
|
|
40
42
|
"claudeConfigDir",
|
|
41
43
|
];
|
|
42
44
|
function readSettingsFile(path) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebUpdate } from "./web-update.js";
|
|
1
2
|
import { WebAuth } from "./web-auth.js";
|
|
2
3
|
type NodeEventSource = {
|
|
3
4
|
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
@@ -37,6 +38,7 @@ export declare class WebServer {
|
|
|
37
38
|
private onPortsExhaustedCallback;
|
|
38
39
|
private onStepAsideCallback;
|
|
39
40
|
private onPowerActionCallback;
|
|
41
|
+
private webUpdate;
|
|
40
42
|
private powerActionRunning;
|
|
41
43
|
private stepAsideTimer;
|
|
42
44
|
private holdOffTimer;
|
|
@@ -45,6 +47,8 @@ export declare class WebServer {
|
|
|
45
47
|
private readonly maxFallbackPort;
|
|
46
48
|
private settingsImportJobs?;
|
|
47
49
|
private backfillControlsInstance?;
|
|
50
|
+
/** The memory store's projects for Directory maps suggestions, read at most once a minute. */
|
|
51
|
+
private storeProjects?;
|
|
48
52
|
/** Run an import-page action; errors carry their own status and never file contents. */
|
|
49
53
|
private importResponse;
|
|
50
54
|
/** Claude Code hook requests; they reach this point only with a valid API token. */
|
|
@@ -57,9 +61,13 @@ export declare class WebServer {
|
|
|
57
61
|
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
58
62
|
setOnStepAside(callback: () => void | Promise<void>): void;
|
|
59
63
|
/** Standalone web apps register this to stop or restart from the page. */
|
|
60
|
-
setOnPowerAction(callback: (action: PowerAction) =>
|
|
64
|
+
setOnPowerAction(callback: (action: PowerAction) => unknown): void;
|
|
65
|
+
/** Standalone web apps register this to report and install npm releases. */
|
|
66
|
+
setWebUpdate(update: Pick<WebUpdate, "status" | "requestInstall">): void;
|
|
61
67
|
/** Only a local caller may stop the web app; the token alone is not enough. */
|
|
62
68
|
private handlePowerAction;
|
|
69
|
+
/** Install the npm release and restart onto it. Same guards as Restart. */
|
|
70
|
+
private handleUpdate;
|
|
63
71
|
/** API token routes. Only a caller on this machine with the local token manages tokens. */
|
|
64
72
|
private handleTokenRoute;
|
|
65
73
|
/** Profile catch-up: a preview for anyone with API access; runs only from this machine. */
|
|
@@ -75,6 +83,7 @@ export declare class WebServer {
|
|
|
75
83
|
start(): Promise<void>;
|
|
76
84
|
private _start;
|
|
77
85
|
private startHealthCheckLoop;
|
|
86
|
+
private ownerIsNewer;
|
|
78
87
|
private stopHealthCheckLoop;
|
|
79
88
|
private attemptTakeover;
|
|
80
89
|
private notifyPortsExhausted;
|