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.
Files changed (128) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +113 -27
  3. package/client.js +3121 -32
  4. package/docs/AGENT-HARNESS.md +144 -0
  5. package/docs/ARCHITECTURE.md +38 -7
  6. package/docs/COMPATIBILITY.md +5 -3
  7. package/docs/DEVELOPMENT.md +56 -0
  8. package/docs/INSTALL.md +20 -11
  9. package/docs/LAPTOP-INSTALL.md +96 -0
  10. package/docs/ROADMAP-3.0.md +353 -0
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/SUBAGENTS.md +25 -0
  13. package/docs/TEST-0.2.2.md +6 -6
  14. package/docs/TEST-2.3.0.md +49 -0
  15. package/docs/TEST-2.7.2.md +46 -0
  16. package/docs/TOKENS.md +2 -2
  17. package/docs/TROUBLESHOOTING.md +27 -0
  18. package/docs/UI-QA.md +28 -0
  19. package/docs/images/peak-toast-peak.png +0 -0
  20. package/docs/images/peak-toast-soon.png +0 -0
  21. package/docs/preview/peak-toast.html +155 -0
  22. package/doctor.ps1 +122 -0
  23. package/index.js +18 -1
  24. package/install.ps1 +99 -0
  25. package/package.json +29 -6
  26. package/plugins/dsh-agent-extensions/README.md +99 -0
  27. package/plugins/dsh-agent-extensions/index.js +167 -0
  28. package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
  29. package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
  30. package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
  31. package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
  32. package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
  33. package/plugins/dsh-agent-extensions/lib/project.js +144 -0
  34. package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
  35. package/plugins/dsh-agent-extensions/package.json +9 -0
  36. package/plugins/dsh-browser-qa/README.md +221 -0
  37. package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
  38. package/plugins/dsh-browser-qa/index.js +165 -0
  39. package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
  40. package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
  41. package/plugins/dsh-browser-qa/lib/pages.js +552 -0
  42. package/plugins/dsh-browser-qa/lib/qa.js +827 -0
  43. package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
  44. package/plugins/dsh-browser-qa/package.json +31 -0
  45. package/plugins/dsh-checkpoints/README.md +22 -0
  46. package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
  47. package/plugins/dsh-checkpoints/index.js +80 -0
  48. package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
  49. package/plugins/dsh-checkpoints/package.json +25 -0
  50. package/plugins/dsh-github-review/README.md +131 -0
  51. package/plugins/dsh-github-review/cordis.patch.yml +3 -0
  52. package/plugins/dsh-github-review/index.js +536 -0
  53. package/plugins/dsh-github-review/lib/gh.js +494 -0
  54. package/plugins/dsh-github-review/lib/normalize.js +662 -0
  55. package/plugins/dsh-github-review/package.json +28 -0
  56. package/plugins/dsh-guardrails/README.md +264 -0
  57. package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
  58. package/plugins/dsh-guardrails/index.js +389 -0
  59. package/plugins/dsh-guardrails/lib/hooks.js +262 -0
  60. package/plugins/dsh-guardrails/lib/modes.js +77 -0
  61. package/plugins/dsh-guardrails/lib/policy.js +592 -0
  62. package/plugins/dsh-guardrails/lib/scan.js +451 -0
  63. package/plugins/dsh-guardrails/lib/secrets.js +274 -0
  64. package/plugins/dsh-guardrails/package.json +26 -0
  65. package/plugins/dsh-mcp-manager/README.md +78 -0
  66. package/plugins/dsh-mcp-manager/client.js +373 -0
  67. package/plugins/dsh-mcp-manager/index.js +159 -0
  68. package/plugins/dsh-mcp-manager/lib/api.js +250 -0
  69. package/plugins/dsh-mcp-manager/lib/client.js +219 -0
  70. package/plugins/dsh-mcp-manager/lib/config.js +217 -0
  71. package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
  72. package/plugins/dsh-mcp-manager/package.json +18 -0
  73. package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
  74. package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
  75. package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
  76. package/plugins/dsh-memory-bridge/README.md +161 -0
  77. package/plugins/dsh-memory-bridge/client.js +2264 -0
  78. package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
  79. package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
  80. package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
  81. package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
  82. package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
  83. package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
  84. package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
  85. package/plugins/dsh-memory-bridge/icon.svg +13 -0
  86. package/plugins/dsh-memory-bridge/index.js +378 -0
  87. package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
  88. package/plugins/dsh-memory-bridge/lib/github.js +473 -0
  89. package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
  90. package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
  91. package/plugins/dsh-memory-bridge/lib/state.js +565 -0
  92. package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
  93. package/plugins/dsh-memory-bridge/locale/en.json +6 -0
  94. package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
  95. package/plugins/dsh-memory-bridge/package.json +67 -0
  96. package/plugins/dsh-peak-guard/README.md +16 -3
  97. package/plugins/dsh-peak-guard/client.js +223 -8
  98. package/plugins/dsh-peak-guard/package.json +1 -1
  99. package/plugins/dsh-plan-mode/README.md +24 -0
  100. package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
  101. package/plugins/dsh-plan-mode/index.js +103 -0
  102. package/plugins/dsh-plan-mode/lib/plan.js +89 -0
  103. package/plugins/dsh-plan-mode/package.json +18 -0
  104. package/plugins/dsh-profile-transfer/client.js +277 -17
  105. package/plugins/dsh-profile-transfer/index.js +10 -0
  106. package/plugins/dsh-profile-transfer/package.json +3 -1
  107. package/plugins/dsh-profile-transfer/transfer.js +101 -2
  108. package/plugins/dsh-project-memory/index.js +92 -2
  109. package/plugins/dsh-project-memory/package.json +1 -1
  110. package/plugins/dsh-repo-map/README.md +26 -0
  111. package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
  112. package/plugins/dsh-repo-map/index.js +99 -0
  113. package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
  114. package/plugins/dsh-repo-map/package.json +25 -0
  115. package/plugins/dsh-skill-autopilot/index.js +135 -11
  116. package/skills/delivery-gate/SKILL.md +31 -0
  117. package/skills/git-and-ci/SKILL.md +5 -1
  118. package/skills/manifest.json +31 -6
  119. package/skills/requirements-and-planning/SKILL.md +3 -0
  120. package/skills/research-first/SKILL.md +35 -0
  121. package/skills/research-first/references/query-patterns.md +20 -0
  122. package/skills/subagent-playbook/SKILL.md +36 -0
  123. package/skills/subagent-playbook/references/prompts.md +39 -0
  124. package/skills/ui-craft/SKILL.md +43 -0
  125. package/skills/visual-qa/SKILL.md +29 -0
  126. package/skills/visual-qa/references/commands.md +32 -0
  127. package/skills-baseline.json +246 -0
  128. 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: true,
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
- let slug = slugify(name);
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 = `${slugify(name)}-${counter}`;
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.2",
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,3 @@
1
+ - insert:
2
+ - id: dsh-repo-map
3
+ name: dsh-repo-map
@@ -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
+ }