dsh-desktop-kit 2.2.0 → 2.7.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 +86 -0
- package/README.md +113 -27
- package/client.js +3121 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +5 -3
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +6 -6
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/doctor.ps1 +122 -0
- package/index.js +18 -1
- package/install.ps1 +99 -0
- package/package.json +29 -6
- package/plugins/dsh-agent-extensions/README.md +99 -0
- package/plugins/dsh-agent-extensions/index.js +167 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +9 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +246 -0
- package/uninstall.ps1 +74 -0
|
@@ -256,7 +256,7 @@ const DEFAULT_SELECTION = {
|
|
|
256
256
|
workspaceFolders: false,
|
|
257
257
|
skills: true,
|
|
258
258
|
memory: true,
|
|
259
|
-
projectsMemory:
|
|
259
|
+
projectsMemory: false,
|
|
260
260
|
sessions: false,
|
|
261
261
|
attachments: false,
|
|
262
262
|
credentials: false,
|
|
@@ -342,6 +342,43 @@ function workspaceFolderEntries(home, options = {}) {
|
|
|
342
342
|
return { entries, workspaces, capped };
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Find transfer archives on a USB drive (or any folder), without unpacking.
|
|
347
|
+
* Used by the "USB / projects and code" shortcut in the Settings page.
|
|
348
|
+
*/
|
|
349
|
+
export function findTransferArchives(directory, { maxDepth = 2 } = {}) {
|
|
350
|
+
const root = path.resolve(String(directory ?? "").trim());
|
|
351
|
+
const found = [];
|
|
352
|
+
const walk = (current, depth) => {
|
|
353
|
+
if (depth > maxDepth) return;
|
|
354
|
+
let entries;
|
|
355
|
+
try {
|
|
356
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
357
|
+
} catch {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
if (entry.name.startsWith(".")) continue;
|
|
362
|
+
const absolute = path.join(current, entry.name);
|
|
363
|
+
if (entry.isDirectory()) {
|
|
364
|
+
if (entry.name === "node_modules" || entry.name === "pgruntime" || entry.name === "tmp") continue;
|
|
365
|
+
walk(absolute, depth + 1);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!entry.isFile() || !/^dsh-profile-.+\.tar\.gz$/u.test(entry.name)) continue;
|
|
369
|
+
try {
|
|
370
|
+
const stat = fs.statSync(absolute);
|
|
371
|
+
found.push({ path: absolute, name: entry.name, bytes: stat.size, human: humanSize(stat.size), mtimeMs: stat.mtimeMs });
|
|
372
|
+
} catch {
|
|
373
|
+
/* unreadable entry */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
walk(root, 0);
|
|
378
|
+
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
379
|
+
return { root, archives: found };
|
|
380
|
+
}
|
|
381
|
+
|
|
345
382
|
/**
|
|
346
383
|
* Describe every exportable group with its real size on disk, so the UI can
|
|
347
384
|
* show what will be included before the user presses the button.
|
|
@@ -752,6 +789,55 @@ function restoreWorkspaceRegistry(home, incoming, manifest) {
|
|
|
752
789
|
return Object.keys(incomingWorkspaces).length;
|
|
753
790
|
}
|
|
754
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Merge `projects-memory/registry.json` instead of overwriting it. The archive
|
|
794
|
+
* may come from a machine with different absolute roots: remap them and keep
|
|
795
|
+
* projects that exist only on this machine. Dossiers themselves are copied by
|
|
796
|
+
* the regular file pass, so local journals are never dropped here.
|
|
797
|
+
*/
|
|
798
|
+
function restoreProjectRegistry(home, incoming, manifest) {
|
|
799
|
+
if (incoming === null || typeof incoming !== "object") return { added: 0, merged: 0 };
|
|
800
|
+
const target = path.join(home, "projects-memory", "registry.json");
|
|
801
|
+
const current = readJson(target);
|
|
802
|
+
const merged = current !== null && typeof current === "object" && Array.isArray(current.projects)
|
|
803
|
+
? current
|
|
804
|
+
: { version: 1, settings: {}, projects: [] };
|
|
805
|
+
merged.version = 1;
|
|
806
|
+
merged.settings = { ...(incoming.settings ?? {}), ...(merged.settings ?? {}) };
|
|
807
|
+
const byFingerprint = new Map();
|
|
808
|
+
const bySlug = new Map();
|
|
809
|
+
for (const project of merged.projects) {
|
|
810
|
+
if (typeof project?.slug !== "string") continue;
|
|
811
|
+
bySlug.set(project.slug, project);
|
|
812
|
+
if (typeof project.fingerprint === "string" && project.fingerprint.length > 0) byFingerprint.set(project.fingerprint, project);
|
|
813
|
+
}
|
|
814
|
+
let added = 0;
|
|
815
|
+
let mergedCount = 0;
|
|
816
|
+
for (const project of Array.isArray(incoming.projects) ? incoming.projects : []) {
|
|
817
|
+
if (project === null || typeof project !== "object" || typeof project.slug !== "string") continue;
|
|
818
|
+
const roots = (Array.isArray(project.roots) ? project.roots : [])
|
|
819
|
+
.filter((root) => typeof root === "string" && root.length > 0)
|
|
820
|
+
.map((root) => remapOriginPath(root, manifest.origin, home));
|
|
821
|
+
const fingerprint = typeof project.fingerprint === "string" ? project.fingerprint : undefined;
|
|
822
|
+
const existing = (fingerprint !== undefined ? byFingerprint.get(fingerprint) : undefined) ?? bySlug.get(project.slug);
|
|
823
|
+
if (existing) {
|
|
824
|
+
existing.roots = [...new Set([...(Array.isArray(existing.roots) ? existing.roots : []), ...roots])].slice(0, 8);
|
|
825
|
+
if (typeof existing.fingerprint !== "string" && fingerprint !== undefined) existing.fingerprint = fingerprint;
|
|
826
|
+
existing.updatedAt = new Date().toISOString();
|
|
827
|
+
mergedCount += 1;
|
|
828
|
+
} else {
|
|
829
|
+
const copy = { ...project, roots };
|
|
830
|
+
merged.projects.push(copy);
|
|
831
|
+
bySlug.set(copy.slug, copy);
|
|
832
|
+
if (fingerprint !== undefined) byFingerprint.set(fingerprint, copy);
|
|
833
|
+
added += 1;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
837
|
+
fs.writeFileSync(target, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
838
|
+
return { added, merged: mergedCount };
|
|
839
|
+
}
|
|
840
|
+
|
|
755
841
|
/** Restore the optional workspace folders next to their (remapped) workspace paths. */
|
|
756
842
|
function restoreWorkspaceFolders(home, entries, manifest, warnings) {
|
|
757
843
|
const groups = new Map();
|
|
@@ -839,7 +925,7 @@ export function applyBundle(options = {}) {
|
|
|
839
925
|
const selection = normalizeSelection(options.selection ?? inspected.manifest.selection);
|
|
840
926
|
const entries = parseTarGz(fs.readFileSync(archivePath));
|
|
841
927
|
const warnings = [];
|
|
842
|
-
const restored = { profile: 0, localPlugins: 0, workspaces: 0, workspaceFolders: 0, skills: 0, memory: 0, projectsMemory: 0, config: 0, sessions: 0, attachments: 0, credentials: 0 };
|
|
928
|
+
const restored = { profile: 0, localPlugins: 0, workspaces: 0, workspaceFolders: 0, skills: 0, memory: 0, projectsMemory: 0, projectsRegistry: { added: 0, merged: 0 }, config: 0, sessions: 0, attachments: 0, credentials: 0 };
|
|
843
929
|
let backupDir;
|
|
844
930
|
if (options.backup !== false) backupDir = backupCurrent(home, profileName, selection);
|
|
845
931
|
|
|
@@ -901,6 +987,8 @@ export function applyBundle(options = {}) {
|
|
|
901
987
|
continue;
|
|
902
988
|
}
|
|
903
989
|
if (relativePath.startsWith("payload/projects-memory/")) {
|
|
990
|
+
// Merged below instead of overwriting the local registry.
|
|
991
|
+
if (relativePath === "payload/projects-memory/registry.json") continue;
|
|
904
992
|
writeEntry(path.join("projects-memory", relativePath.slice("payload/projects-memory/".length)), entry.data);
|
|
905
993
|
restored.projectsMemory += 1;
|
|
906
994
|
continue;
|
|
@@ -941,6 +1029,17 @@ export function applyBundle(options = {}) {
|
|
|
941
1029
|
}
|
|
942
1030
|
}
|
|
943
1031
|
|
|
1032
|
+
// Project registry: merge, never replace this machine's projects. Roots are
|
|
1033
|
+
// remapped from the exporting machine (user home / DSH home).
|
|
1034
|
+
const projectRegistryEntry = entries.find((entry) => entry.type === "0" && safeRelative(entry.path) === "payload/projects-memory/registry.json");
|
|
1035
|
+
if (projectRegistryEntry !== undefined && selection.projectsMemory !== false) {
|
|
1036
|
+
try {
|
|
1037
|
+
restored.projectsRegistry = restoreProjectRegistry(home, JSON.parse(projectRegistryEntry.data.toString("utf8")), inspected.manifest);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
warnings.push(`реестр проектов не слит: ${String(error?.message ?? error)}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
944
1043
|
// Optional workspace folders: restored next to their (remapped) workspace paths.
|
|
945
1044
|
if (selection.workspaceFolders === true) {
|
|
946
1045
|
const folderEntries = entries.filter((entry) => entry.type === "0" && safeRelative(entry.path).startsWith("payload/workspace-folders/"));
|
|
@@ -394,6 +394,58 @@ function ensureProjectDossier(project, options = {}) {
|
|
|
394
394
|
}
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
function readJsonSafe(filePath) {
|
|
398
|
+
try {
|
|
399
|
+
return JSON.parse(readText(filePath));
|
|
400
|
+
} catch {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* A dossier can arrive before its registry entry does: dsh-memory-bridge pulls
|
|
407
|
+
* dossiers from the private exchange repository, and dsh-profile-transfer
|
|
408
|
+
* copies them from another machine. Adopt that folder instead of creating
|
|
409
|
+
* `<slug>-2`, otherwise the synced dossier stays orphaned and the bridge
|
|
410
|
+
* publishes a second project with a new exchange folder.
|
|
411
|
+
*/
|
|
412
|
+
function findSyncedDossier(registry, { name, fingerprint }) {
|
|
413
|
+
let entries;
|
|
414
|
+
try {
|
|
415
|
+
entries = fs.readdirSync(MEMORY_ROOT, { withFileTypes: true });
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const registered = new Set(registry.projects.map((project) => project.slug));
|
|
420
|
+
const expected = slugify(name);
|
|
421
|
+
let fallback;
|
|
422
|
+
for (const entry of entries) {
|
|
423
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
424
|
+
if (registered.has(entry.name)) continue;
|
|
425
|
+
const directory = path.join(MEMORY_ROOT, entry.name);
|
|
426
|
+
if (!exists(path.join(directory, "PROJECT.md"))) continue;
|
|
427
|
+
const meta = readJsonSafe(path.join(directory, "project.json"));
|
|
428
|
+
if (typeof fingerprint === "string" && fingerprint.length > 0 && meta?.fingerprint === fingerprint) {
|
|
429
|
+
return { slug: entry.name, name: typeof meta.name === "string" && meta.name.length > 0 ? meta.name : name };
|
|
430
|
+
}
|
|
431
|
+
if (fallback === undefined && entry.name === expected) fallback = { slug: entry.name, name };
|
|
432
|
+
}
|
|
433
|
+
return fallback;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* A registry entry whose roots are gone was imported from another machine.
|
|
438
|
+
* Re-point it at the local checkout instead of creating a duplicate project.
|
|
439
|
+
*/
|
|
440
|
+
function findStaleProject(registry, slug, root) {
|
|
441
|
+
const project = registry.projects.find((entry) => entry.slug === slug);
|
|
442
|
+
if (!project) return undefined;
|
|
443
|
+
const roots = Array.isArray(project.roots) ? project.roots.filter((entry) => typeof entry === "string") : [];
|
|
444
|
+
if (roots.some((entry) => normalizePath(entry) === normalizePath(root))) return undefined;
|
|
445
|
+
if (roots.some((entry) => exists(entry))) return undefined;
|
|
446
|
+
return project;
|
|
447
|
+
}
|
|
448
|
+
|
|
397
449
|
function createProject(registry, { name, root, stack, legacyDocs, autoCreated, confidence }) {
|
|
398
450
|
if (typeof name !== "string" || name.trim().length === 0) throw new Error("project name is required");
|
|
399
451
|
if (typeof root !== "string" || root.trim().length === 0) throw new Error("project root is required");
|
|
@@ -414,10 +466,48 @@ function createProject(registry, { name, root, stack, legacyDocs, autoCreated, c
|
|
|
414
466
|
logAccess(`project-merged slug=${twin.slug} root=${resolvedRoot}`);
|
|
415
467
|
return projectPublic(twin);
|
|
416
468
|
}
|
|
417
|
-
|
|
469
|
+
const expectedSlug = slugify(name);
|
|
470
|
+
const stale = findStaleProject(registry, expectedSlug, resolvedRoot);
|
|
471
|
+
if (stale) {
|
|
472
|
+
stale.roots = [resolvedRoot];
|
|
473
|
+
stale.fingerprint = fingerprint;
|
|
474
|
+
stale.updatedAt = nowIso();
|
|
475
|
+
if (typeof stale.stack !== "string" && typeof stack === "string") stale.stack = stack.slice(0, 200);
|
|
476
|
+
saveRegistry(registry);
|
|
477
|
+
ensureProjectDossier(stale);
|
|
478
|
+
logAccess(`project-relinked slug=${stale.slug} root=${resolvedRoot}`);
|
|
479
|
+
return projectPublic(stale);
|
|
480
|
+
}
|
|
481
|
+
const synced = findSyncedDossier(registry, { name, fingerprint });
|
|
482
|
+
if (synced) {
|
|
483
|
+
const project = {
|
|
484
|
+
slug: synced.slug,
|
|
485
|
+
name: String(synced.name || name).trim(),
|
|
486
|
+
roots: [resolvedRoot],
|
|
487
|
+
fingerprint,
|
|
488
|
+
legacyDocs: [],
|
|
489
|
+
stack: typeof stack === "string" ? stack.slice(0, 200) : undefined,
|
|
490
|
+
createdAt: nowIso(),
|
|
491
|
+
updatedAt: nowIso(),
|
|
492
|
+
lastAnalyzedAt: null,
|
|
493
|
+
lastScanAt: null,
|
|
494
|
+
lastScanHead: null,
|
|
495
|
+
scanVersion: null,
|
|
496
|
+
lastJournalAt: null,
|
|
497
|
+
lastGitHead: null,
|
|
498
|
+
confidence: "imported",
|
|
499
|
+
autoCreated: autoCreated === true,
|
|
500
|
+
};
|
|
501
|
+
registry.projects.push(project);
|
|
502
|
+
saveRegistry(registry);
|
|
503
|
+
ensureProjectDossier(project);
|
|
504
|
+
logAccess(`project-adopted slug=${project.slug} root=${resolvedRoot}`);
|
|
505
|
+
return projectPublic(project);
|
|
506
|
+
}
|
|
507
|
+
let slug = expectedSlug;
|
|
418
508
|
let counter = 2;
|
|
419
509
|
while (registry.projects.some((project) => project.slug === slug) || exists(path.join(MEMORY_ROOT, slug))) {
|
|
420
|
-
slug = `${
|
|
510
|
+
slug = `${expectedSlug}-${counter}`;
|
|
421
511
|
counter += 1;
|
|
422
512
|
}
|
|
423
513
|
const project = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-project-memory",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Local per-project long-term memory for DSH: auto-detection, bounded local recon, auto-injected digest with recall, project_memory tool, automatic session journal.",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-repo-map
|
|
2
|
+
|
|
3
|
+
Локальная структурная карта проекта для кодинг-задач: файлы, языки, символы,
|
|
4
|
+
импорты, PageRank-подобный рейтинг и поиск релевантных файлов под запрос.
|
|
5
|
+
|
|
6
|
+
## Зачем
|
|
7
|
+
|
|
8
|
+
Модель не читает весь репозиторий. `repo_map` даёт «оглавление» и подсказывает,
|
|
9
|
+
какие 5–15 файлов действительно стоит открыть — дешевле и точнее, чем слепой grep.
|
|
10
|
+
|
|
11
|
+
## Инструмент
|
|
12
|
+
|
|
13
|
+
`repo_map`:
|
|
14
|
+
|
|
15
|
+
- `action: "map"` — дерево/топ файлов, символы, импорты;
|
|
16
|
+
- `action: "find"` — релевантные файлы по запросу (`query`);
|
|
17
|
+
- `action: "stats"` — количество файлов и языков;
|
|
18
|
+
- `action: "refresh"` — сбросить кэш.
|
|
19
|
+
|
|
20
|
+
`root` по умолчанию — cwd текущей сессии DSH. Кэш — 60 секунд, без сети и LLM.
|
|
21
|
+
|
|
22
|
+
## Ограничения
|
|
23
|
+
|
|
24
|
+
- Эвристика по расширениям и регулярным выражениям; это карта, а не type-checker.
|
|
25
|
+
- Пропускает `node_modules`, `.git`, `dist`, `build`, `vendor`, `pgruntime`, `tmp`, `.dsh`.
|
|
26
|
+
- Для очень больших репозиториев действует лимит файлов (по умолчанию 2500).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-repo-map — compact local repository map for coding tasks.
|
|
3
|
+
*
|
|
4
|
+
* The model gets a structural "table of contents" (files, symbols, imports,
|
|
5
|
+
* ranked relevance) instead of reading the whole tree. No network, no LLM.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { buildRepoMap, findRelevant } from "./lib/repo-map.js";
|
|
10
|
+
|
|
11
|
+
export const inject = ["tools"];
|
|
12
|
+
|
|
13
|
+
const CACHE_MS = 60_000;
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
|
|
16
|
+
function rootOf(args, exec) {
|
|
17
|
+
const explicit = typeof args?.root === "string" ? args.root.trim() : "";
|
|
18
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
19
|
+
return path.resolve(explicit || cwd || process.cwd());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function indexPath(root, extra = {}) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const cached = cache.get(root);
|
|
25
|
+
if (cached && now - cached.at < CACHE_MS && !extra.refresh) return cached.promise;
|
|
26
|
+
const promise = buildRepoMap(root, extra).catch((error) => {
|
|
27
|
+
cache.delete(root);
|
|
28
|
+
throw error;
|
|
29
|
+
});
|
|
30
|
+
cache.set(root, { at: now, promise });
|
|
31
|
+
return promise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toolResult(value) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncate(text, max = 24_000) {
|
|
39
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n…`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function apply(ctx) {
|
|
43
|
+
const tool = {
|
|
44
|
+
name: "repo_map",
|
|
45
|
+
description:
|
|
46
|
+
"Local structural map of the current repository: top files, languages, symbols and imports, plus relevance search. Use before broad code exploration; then read only the relevant files. No network, no LLM.",
|
|
47
|
+
parameters: {
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
action: { type: "string", enum: ["map", "find", "stats", "refresh"], description: "map | find | stats | refresh" },
|
|
52
|
+
query: { type: "string", description: "Task/query for find: names, routes, errors, domain words." },
|
|
53
|
+
root: { type: "string", description: "Absolute project root; defaults to the current Session cwd." },
|
|
54
|
+
limit: { type: "integer", description: "Max files for find (1..50, default 12)." },
|
|
55
|
+
top: { type: "integer", description: "How many files to include in map (10..60)." },
|
|
56
|
+
},
|
|
57
|
+
required: ["action"],
|
|
58
|
+
},
|
|
59
|
+
timeoutMs: 25_000,
|
|
60
|
+
output: {
|
|
61
|
+
schema: { type: "object", additionalProperties: true },
|
|
62
|
+
render(_args, value) {
|
|
63
|
+
const text = typeof value?.text === "string" ? value.text : JSON.stringify(value);
|
|
64
|
+
return [{ type: "text", text: truncate(text) }];
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
async execute(args, exec) {
|
|
68
|
+
const root = rootOf(args, exec);
|
|
69
|
+
const action = String(args?.action ?? "map");
|
|
70
|
+
if (action === "find") {
|
|
71
|
+
const query = String(args?.query ?? "").trim();
|
|
72
|
+
if (query.length === 0) return toolResult({ ok: false, text: "repo_map find: укажите query" });
|
|
73
|
+
const map = await indexPath(root);
|
|
74
|
+
const relevant = await findRelevant(root, query, { map, limit: args?.limit });
|
|
75
|
+
const text = relevant.length === 0
|
|
76
|
+
? `В ${path.basename(root)} ничего не найдено по запросу «${query}».`
|
|
77
|
+
: [
|
|
78
|
+
`Релевантные файлы для «${query}» (${relevant.length}):`,
|
|
79
|
+
...relevant.map((entry) => `- ${entry.path}${entry.symbols.length > 0 ? ` — ${entry.symbols.slice(0, 6).join(", ")}` : ""}`),
|
|
80
|
+
].join("\n");
|
|
81
|
+
return toolResult({ ok: true, root, query, files: relevant.map((entry) => entry.path), text });
|
|
82
|
+
}
|
|
83
|
+
if (action === "stats") {
|
|
84
|
+
const map = await indexPath(root, { refresh: args?.action === "refresh" });
|
|
85
|
+
return toolResult({
|
|
86
|
+
ok: true,
|
|
87
|
+
root,
|
|
88
|
+
files: map.files,
|
|
89
|
+
languages: map.languages,
|
|
90
|
+
text: `Файлов: ${map.files}\nЯзыки: ${Object.entries(map.languages).map(([name, count]) => `${name} ${count}`).join(", ")}`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const map = await indexPath(root);
|
|
94
|
+
return toolResult({ ok: true, root, files: map.files, languages: map.languages, text: map.mapText });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
ctx.tools.register(tool);
|
|
98
|
+
ctx.logger?.info?.(`[repo-map] tool ready`);
|
|
99
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
"node_modules", ".git", ".svn", ".hg", "dist", "build", "out", "coverage",
|
|
6
|
+
".next", ".nuxt", ".cache", "vendor", "pgruntime", "tmp", ".dsh",
|
|
7
|
+
]);
|
|
8
|
+
const TEXT_EXT = new Map([
|
|
9
|
+
[".js", "javascript"], [".mjs", "javascript"], [".cjs", "javascript"],
|
|
10
|
+
[".ts", "typescript"], [".tsx", "typescript"], [".jsx", "javascript"],
|
|
11
|
+
[".vue", "vue"], [".svelte", "svelte"], [".php", "php"], [".py", "python"],
|
|
12
|
+
[".go", "go"], [".rs", "rust"], [".rb", "ruby"], [".java", "java"],
|
|
13
|
+
[".cs", "csharp"], [".kt", "kotlin"], [".swift", "swift"], [".json", "json"],
|
|
14
|
+
[".md", "markdown"], [".yml", "yaml"], [".yaml", "yaml"], [".toml", "toml"],
|
|
15
|
+
[".sql", "sql"], [".sh", "shell"], [".ps1", "powershell"],
|
|
16
|
+
]);
|
|
17
|
+
const MAX_FILE_BYTES = 160 * 1024;
|
|
18
|
+
|
|
19
|
+
function safeRead(file) {
|
|
20
|
+
try {
|
|
21
|
+
const stat = fs.statSync(file);
|
|
22
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return "";
|
|
23
|
+
return fs.readFileSync(file, "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function walkProject(root, { maxFiles = 2500 } = {}) {
|
|
30
|
+
const files = [];
|
|
31
|
+
const walk = (directory) => {
|
|
32
|
+
if (files.length >= maxFiles) return;
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (files.length >= maxFiles) break;
|
|
41
|
+
if (entry.name.startsWith(".")) continue;
|
|
42
|
+
const absolute = path.join(directory, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
if (!SKIP_DIRS.has(entry.name)) walk(absolute);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!entry.isFile()) continue;
|
|
48
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
49
|
+
if (!TEXT_EXT.has(ext)) continue;
|
|
50
|
+
files.push({ absolute, relative: path.relative(root, absolute).replaceAll("\\", "/"), ext, language: TEXT_EXT.get(ext) });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(root);
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function symbolsOf(text, language) {
|
|
58
|
+
const symbols = new Set();
|
|
59
|
+
const patterns = [
|
|
60
|
+
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?class\s+([A-Za-z_$][\w$]*)/gu,
|
|
61
|
+
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gu,
|
|
62
|
+
/(?:^|\n)\s*(?:export\s+)?(?:const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gu,
|
|
63
|
+
/(?:^|\n)\s*def\s+([A-Za-z_][\w]*)/gu,
|
|
64
|
+
/(?:^|\n)\s*class\s+([A-Za-z_][\w]*)/gu,
|
|
65
|
+
/(?:^|\n)\s*function\s+([A-Za-z_][\w]*)/gu,
|
|
66
|
+
/(?:^|\n)\s*(?:public|private|protected|static|final|\s)*function\s+([A-Za-z_][\w]*)/gu,
|
|
67
|
+
];
|
|
68
|
+
for (const pattern of patterns) {
|
|
69
|
+
for (const match of text.matchAll(pattern)) {
|
|
70
|
+
if (match[1] && !["if", "for", "while", "switch", "catch"].includes(match[1])) symbols.add(match[1]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...symbols].slice(0, 40);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function importsOf(text) {
|
|
77
|
+
const imports = new Set();
|
|
78
|
+
const patterns = [
|
|
79
|
+
/\bfrom\s+["']([^"']+)["']/gu,
|
|
80
|
+
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/gu,
|
|
81
|
+
/\bimport\s+["']([^"']+)["']/gu,
|
|
82
|
+
/\buse\s+([A-Za-z_\\][\w\\]*)/gu,
|
|
83
|
+
];
|
|
84
|
+
for (const pattern of patterns) for (const match of text.matchAll(pattern)) if (match[1]) imports.add(match[1]);
|
|
85
|
+
return [...imports].slice(0, 60);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveImport(fromFile, spec, fileSet) {
|
|
89
|
+
if (!spec.startsWith(".")) return null;
|
|
90
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
91
|
+
const candidates = [base, `${base}.js`, `${base}.mjs`, `${base}.cjs`, `${base}.ts`, `${base}.tsx`, `${base}.jsx`, `${base}.php`, `${base}.py`, `${base}/index.js`, `${base}/index.ts`];
|
|
92
|
+
return candidates.find((candidate) => fileSet.has(candidate)) ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a compact, deterministic repository map. No network, no model calls.
|
|
97
|
+
* @returns {Promise<{root:string, files:number, languages:Record<string,number>, entries:Array, mapText:string}>}
|
|
98
|
+
*/
|
|
99
|
+
export async function buildRepoMap(root, options = {}) {
|
|
100
|
+
const resolved = path.resolve(root);
|
|
101
|
+
const files = walkProject(resolved, options);
|
|
102
|
+
const fileSet = new Set(files.map((file) => file.relative));
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const text = safeRead(file.absolute);
|
|
106
|
+
const symbols = symbolsOf(text, file.language);
|
|
107
|
+
const imports = importsOf(text);
|
|
108
|
+
const links = imports.map((spec) => resolveImport(file.relative, spec, fileSet)).filter(Boolean);
|
|
109
|
+
entries.push({
|
|
110
|
+
path: file.relative,
|
|
111
|
+
language: file.language,
|
|
112
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
113
|
+
lines: text.split(/\r?\n/u).length,
|
|
114
|
+
symbols,
|
|
115
|
+
imports,
|
|
116
|
+
links,
|
|
117
|
+
score: 1 + links.length + symbols.length * 0.35 + Math.max(0, 4 - file.relative.split("/").length) * 0.4,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
121
|
+
for (let round = 0; round < 12; round += 1) {
|
|
122
|
+
const next = new Map(entries.map((entry) => [entry.path, entry.score * 0.65]));
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
for (const link of entry.links) {
|
|
125
|
+
const target = byPath.get(link);
|
|
126
|
+
if (target) next.set(link, (next.get(link) ?? 0) + entry.score * 0.35 / Math.max(1, entry.links.length));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const entry of entries) entry.score = next.get(entry.path) ?? entry.score;
|
|
130
|
+
}
|
|
131
|
+
entries.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
|
|
132
|
+
const languages = {};
|
|
133
|
+
for (const entry of entries) languages[entry.language] = (languages[entry.language] ?? 0) + 1;
|
|
134
|
+
const top = entries.slice(0, Math.max(10, Math.min(60, Number(options.top) || 40)));
|
|
135
|
+
const mapText = [
|
|
136
|
+
`Repository: ${path.basename(resolved)} · ${entries.length} files`,
|
|
137
|
+
`Languages: ${Object.entries(languages).map(([name, count]) => `${name} ${count}`).join(", ")}`,
|
|
138
|
+
"",
|
|
139
|
+
...top.map((entry) => {
|
|
140
|
+
const symbols = entry.symbols.slice(0, 6).join(", ");
|
|
141
|
+
return `- ${entry.path}${symbols ? ` — ${symbols}` : ""}`;
|
|
142
|
+
}),
|
|
143
|
+
].join("\n");
|
|
144
|
+
return { root: resolved, files: entries.length, languages, entries, mapText };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tokens(value) {
|
|
148
|
+
return String(value ?? "")
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.split(/[^a-zа-я0-9_$]+/u)
|
|
151
|
+
.filter((part) => part.length >= 2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Rank files for a query using path, symbols, imports and content-free heuristics. */
|
|
155
|
+
export async function findRelevant(root, query, options = {}) {
|
|
156
|
+
const map = options.map ?? (await buildRepoMap(root, options));
|
|
157
|
+
const queryTokens = tokens(query);
|
|
158
|
+
const limit = Math.max(1, Math.min(50, Number(options.limit) || 12));
|
|
159
|
+
const scored = map.entries.map((entry) => {
|
|
160
|
+
const haystack = `${entry.path} ${entry.symbols.join(" ")} ${entry.imports.join(" ")}`.toLowerCase();
|
|
161
|
+
let score = 0;
|
|
162
|
+
for (const token of queryTokens) {
|
|
163
|
+
if (entry.path.toLowerCase().includes(token)) score += 4;
|
|
164
|
+
if (entry.symbols.some((symbol) => symbol.toLowerCase().includes(token))) score += 5;
|
|
165
|
+
if (haystack.includes(token)) score += 1;
|
|
166
|
+
}
|
|
167
|
+
return { ...entry, queryScore: score > 0 ? score + entry.score * 0.01 : 0 };
|
|
168
|
+
});
|
|
169
|
+
return scored.filter((entry) => entry.queryScore > 0).sort((a, b) => b.queryScore - a.queryScore).slice(0, limit);
|
|
170
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-repo-map",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Compact local repository map and relevance search tool for DSH coding tasks: files, symbols, imports and ranking without network or LLM calls.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./lib": "./lib/repo-map.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"lib/**",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dsh": {
|
|
20
|
+
"manifestVersion": 1,
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|