@mrc2204/agent-smart-memo 5.0.0 → 5.0.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.
@@ -1,3 +1,7 @@
1
+ import { execSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
1
5
  function asRecord(value) {
2
6
  return value && typeof value === "object" && !Array.isArray(value)
3
7
  ? value
@@ -28,6 +32,13 @@ function allScopeIdentities(ctx) {
28
32
  { userId: "__public__", agentId: "__public__", scope: "public" },
29
33
  ];
30
34
  }
35
+ function randomJobId() {
36
+ return `idxjob_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
37
+ }
38
+ function shellEscape(value) {
39
+ const input = String(value || "");
40
+ return `'${input.replace(/'/g, `'"'"'`)}'`;
41
+ }
31
42
  export class DefaultMemoryUseCasePort {
32
43
  slotDb;
33
44
  semanticUseCase;
@@ -46,6 +57,36 @@ export class DefaultMemoryUseCasePort {
46
57
  return this.handleSlotList(payload, req);
47
58
  case "slot.delete":
48
59
  return this.handleSlotDelete(payload, req);
60
+ case "project.register":
61
+ return this.handleProjectRegister(payload, req);
62
+ case "project.get":
63
+ return this.handleProjectGet(payload, req);
64
+ case "project.list":
65
+ return this.handleProjectList(req);
66
+ case "project.set_registration_state":
67
+ return this.handleProjectSetRegistrationState(payload, req);
68
+ case "project.set_tracker_mapping":
69
+ return this.handleProjectSetTrackerMapping(payload, req);
70
+ case "project.register_command":
71
+ return this.handleProjectRegisterCommand(payload, req);
72
+ case "project.link_tracker":
73
+ return this.handleProjectLinkTracker(payload, req);
74
+ case "project.trigger_index":
75
+ return this.handleProjectTriggerIndex(payload, req);
76
+ case "project.reindex_diff":
77
+ return this.handleProjectReindexDiff(payload, req);
78
+ case "project.index_watch_get":
79
+ return this.handleProjectIndexWatchGet(payload, req);
80
+ case "project.task_registry_upsert":
81
+ return this.handleProjectTaskRegistryUpsert(payload, req);
82
+ case "project.task_lineage_context":
83
+ return this.handleProjectTaskLineageContext(payload, req);
84
+ case "project.hybrid_search":
85
+ return this.handleProjectHybridSearch(payload, req);
86
+ case "project.legacy_backfill":
87
+ return this.handleProjectLegacyBackfill(payload, req);
88
+ case "project.telegram_onboarding":
89
+ return this.handleProjectTelegramOnboarding(payload, req);
49
90
  case "graph.entity.get":
50
91
  return this.handleGraphEntityGet(payload, req);
51
92
  case "graph.entity.set":
@@ -166,6 +207,838 @@ export class DefaultMemoryUseCasePort {
166
207
  scope: identity.scope,
167
208
  };
168
209
  }
210
+ handleProjectRegister(payload, req) {
211
+ const identity = normalizePrivateIdentity(req.context);
212
+ if (!payload.project_alias || typeof payload.project_alias !== "string") {
213
+ throw new Error("project.register requires payload.project_alias");
214
+ }
215
+ return this.slotDb.registerProject(identity.userId, identity.agentId, {
216
+ project_id: payload.project_id,
217
+ project_name: payload.project_name,
218
+ project_alias: payload.project_alias,
219
+ repo_root: payload.repo_root,
220
+ repo_remote: payload.repo_remote,
221
+ active_version: payload.active_version,
222
+ allow_alias_update: payload.allow_alias_update,
223
+ });
224
+ }
225
+ handleProjectGet(payload, req) {
226
+ const identity = normalizePrivateIdentity(req.context);
227
+ if (!payload.project_id && !payload.project_alias) {
228
+ throw new Error("project.get requires payload.project_id or payload.project_alias");
229
+ }
230
+ if (payload.project_id) {
231
+ const project = this.slotDb.getProjectById(identity.userId, identity.agentId, payload.project_id);
232
+ if (!project)
233
+ return null;
234
+ return {
235
+ project,
236
+ registration: this.slotDb.getProjectRegistrationState(identity.userId, identity.agentId, payload.project_id),
237
+ };
238
+ }
239
+ const byAlias = this.slotDb.getProjectByAlias(identity.userId, identity.agentId, payload.project_alias);
240
+ if (!byAlias)
241
+ return null;
242
+ return {
243
+ project: byAlias.project,
244
+ alias: byAlias.alias,
245
+ registration: this.slotDb.getProjectRegistrationState(identity.userId, identity.agentId, byAlias.project.project_id),
246
+ };
247
+ }
248
+ handleProjectList(req) {
249
+ const identity = normalizePrivateIdentity(req.context);
250
+ return this.slotDb.listProjects(identity.userId, identity.agentId);
251
+ }
252
+ handleProjectSetRegistrationState(payload, req) {
253
+ const identity = normalizePrivateIdentity(req.context);
254
+ if (!payload.project_id) {
255
+ throw new Error("project.set_registration_state requires payload.project_id");
256
+ }
257
+ return this.slotDb.updateProjectRegistrationState(identity.userId, identity.agentId, {
258
+ project_id: payload.project_id,
259
+ registration_status: payload.registration_status,
260
+ validation_status: payload.validation_status,
261
+ validation_notes: payload.validation_notes ?? null,
262
+ completeness_score: payload.completeness_score,
263
+ missing_required_fields: payload.missing_required_fields || [],
264
+ last_validated_at: payload.last_validated_at,
265
+ });
266
+ }
267
+ handleProjectSetTrackerMapping(payload, req) {
268
+ const identity = normalizePrivateIdentity(req.context);
269
+ if (!payload.project_id || !payload.tracker_type) {
270
+ throw new Error("project.set_tracker_mapping requires payload.project_id and payload.tracker_type");
271
+ }
272
+ if (payload.tracker_type === "jira") {
273
+ this.validateJiraTrackerFields(payload.tracker_space_key, payload.default_epic_key);
274
+ }
275
+ return this.slotDb.setProjectTrackerMapping(identity.userId, identity.agentId, {
276
+ project_id: payload.project_id,
277
+ tracker_type: payload.tracker_type,
278
+ tracker_space_key: payload.tracker_space_key,
279
+ tracker_project_id: payload.tracker_project_id,
280
+ default_epic_key: payload.default_epic_key,
281
+ board_key: payload.board_key,
282
+ active_version: payload.active_version,
283
+ external_project_url: payload.external_project_url,
284
+ });
285
+ }
286
+ handleProjectRegisterCommand(payload, req) {
287
+ const identity = normalizePrivateIdentity(req.context);
288
+ const alias = String(payload.project_alias || "").trim();
289
+ if (!alias) {
290
+ throw new Error("project.register_command requires payload.project_alias");
291
+ }
292
+ const repoUrl = String(payload.repo_url || payload.repo_remote || "").trim() || undefined;
293
+ const workspaceRoot = this.resolveWorkspaceRoot(req);
294
+ const selection = this.resolveRepoForRegistration(identity.userId, identity.agentId, {
295
+ explicitRepoRoot: payload.repo_root,
296
+ repoUrl,
297
+ workspaceRoot,
298
+ });
299
+ const resolvedRepoRoot = selection.repo_root;
300
+ const resolvedRepoRemote = payload.repo_remote || selection.repo_remote || repoUrl;
301
+ const registered = this.slotDb.registerProject(identity.userId, identity.agentId, {
302
+ project_id: payload.project_id,
303
+ project_name: payload.project_name,
304
+ project_alias: alias,
305
+ repo_root: resolvedRepoRoot,
306
+ repo_remote: resolvedRepoRemote,
307
+ active_version: payload.active_version,
308
+ allow_alias_update: payload.options?.allow_alias_update,
309
+ reuse_existing_repo_root: selection.resolution === "registered_remote_match",
310
+ });
311
+ let trackerMapping = null;
312
+ if (payload.tracker?.tracker_type) {
313
+ if (payload.tracker.tracker_type === "jira") {
314
+ this.validateJiraTrackerFields(payload.tracker.tracker_space_key, payload.tracker.default_epic_key);
315
+ }
316
+ trackerMapping = this.slotDb.setProjectTrackerMapping(identity.userId, identity.agentId, {
317
+ project_id: registered.project.project_id,
318
+ tracker_type: payload.tracker.tracker_type,
319
+ tracker_space_key: payload.tracker.tracker_space_key,
320
+ tracker_project_id: payload.tracker.tracker_project_id,
321
+ default_epic_key: payload.tracker.default_epic_key,
322
+ board_key: payload.tracker.board_key,
323
+ active_version: payload.tracker.active_version || payload.active_version,
324
+ external_project_url: payload.tracker.external_project_url,
325
+ });
326
+ }
327
+ const triggerRequested = payload.options?.trigger_index === true;
328
+ let indexTrigger = {
329
+ requested: triggerRequested,
330
+ accepted: false,
331
+ enqueued: false,
332
+ run_id: null,
333
+ job_id: null,
334
+ note: triggerRequested ? "index requested but not enqueued" : null,
335
+ };
336
+ if (triggerRequested) {
337
+ const triggerResult = this.handleProjectTriggerIndex({
338
+ project_ref: { project_id: registered.project.project_id },
339
+ mode: "bootstrap",
340
+ reason: "post_registration",
341
+ paths: [],
342
+ }, req);
343
+ indexTrigger = {
344
+ requested: true,
345
+ accepted: Boolean(triggerResult?.accepted),
346
+ enqueued: Boolean(triggerResult?.enqueued),
347
+ run_id: triggerResult?.run_id || null,
348
+ job_id: triggerResult?.job_id || null,
349
+ note: triggerResult?.note || null,
350
+ };
351
+ }
352
+ return {
353
+ project_id: registered.project.project_id,
354
+ project_alias: registered.alias.project_alias,
355
+ registration_status: registered.registration.registration_status,
356
+ validation_status: registered.registration.validation_status,
357
+ completeness_score: Number((registered.registration.completeness_score / 100).toFixed(2)),
358
+ warnings: [],
359
+ repo_resolution: {
360
+ resolution: selection.resolution,
361
+ clone_policy: selection.clone_policy || "not_applicable",
362
+ workspace_root: selection.workspace_root || null,
363
+ clone_target: selection.clone_target || null,
364
+ notes: selection.notes,
365
+ },
366
+ tracker_mapping: trackerMapping
367
+ ? {
368
+ tracker_type: trackerMapping.tracker_type,
369
+ tracker_space_key: trackerMapping.tracker_space_key,
370
+ default_epic_key: trackerMapping.default_epic_key,
371
+ mapping_status: "linked",
372
+ }
373
+ : null,
374
+ index_trigger: indexTrigger,
375
+ project: registered.project,
376
+ registration: registered.registration,
377
+ };
378
+ }
379
+ handleProjectLinkTracker(payload, req) {
380
+ const identity = normalizePrivateIdentity(req.context);
381
+ const mode = payload.mode || "attach_or_update";
382
+ if (mode !== "attach_or_update") {
383
+ throw new Error("project.link_tracker only supports mode=attach_or_update");
384
+ }
385
+ const project = this.resolveProjectRef(identity.userId, identity.agentId, payload.project_ref);
386
+ if (!payload.tracker?.tracker_type) {
387
+ throw new Error("project.link_tracker requires tracker.tracker_type");
388
+ }
389
+ if (payload.tracker.tracker_type === "jira") {
390
+ this.validateJiraTrackerFields(payload.tracker.tracker_space_key, payload.tracker.default_epic_key);
391
+ }
392
+ const mapped = this.slotDb.setProjectTrackerMapping(identity.userId, identity.agentId, {
393
+ project_id: project.project_id,
394
+ tracker_type: payload.tracker.tracker_type,
395
+ tracker_space_key: payload.tracker.tracker_space_key,
396
+ tracker_project_id: payload.tracker.tracker_project_id,
397
+ default_epic_key: payload.tracker.default_epic_key,
398
+ board_key: payload.tracker.board_key,
399
+ active_version: payload.tracker.active_version,
400
+ external_project_url: payload.tracker.external_project_url,
401
+ });
402
+ return {
403
+ project_id: project.project_id,
404
+ tracker_mapping: {
405
+ tracker_type: mapped.tracker_type,
406
+ tracker_space_key: mapped.tracker_space_key,
407
+ default_epic_key: mapped.default_epic_key,
408
+ mapping_status: "linked",
409
+ updated: true,
410
+ },
411
+ validation_status: "ok",
412
+ warnings: [],
413
+ };
414
+ }
415
+ handleProjectTriggerIndex(payload, req) {
416
+ const identity = normalizePrivateIdentity(req.context);
417
+ const project = this.resolveProjectRef(identity.userId, identity.agentId, payload.project_ref);
418
+ const queuedAt = new Date().toISOString();
419
+ const jobId = randomJobId();
420
+ const normalizedPaths = (payload.paths || []).filter((item) => String(item.relative_path || "").trim().length > 0);
421
+ this.scheduleProjectReindexJob({
422
+ scopeUserId: identity.userId,
423
+ scopeAgentId: identity.agentId,
424
+ projectId: project.project_id,
425
+ sourceRev: payload.source_rev || null,
426
+ triggerType: payload.mode || "bootstrap",
427
+ indexProfile: payload.index_profile || "default",
428
+ paths: normalizedPaths,
429
+ jobId,
430
+ });
431
+ return {
432
+ project_id: project.project_id,
433
+ accepted: true,
434
+ enqueued: true,
435
+ detached: true,
436
+ run_id: null,
437
+ job_id: jobId,
438
+ queued_at: queuedAt,
439
+ reason: payload.reason || "manual_trigger",
440
+ path_count: normalizedPaths.length,
441
+ note: normalizedPaths.length > 0
442
+ ? "index request accepted/enqueued in background mode"
443
+ : "index request accepted/enqueued in background mode; no concrete paths provided yet",
444
+ };
445
+ }
446
+ scheduleProjectReindexJob(input) {
447
+ setTimeout(() => {
448
+ try {
449
+ let paths = input.paths;
450
+ if (paths.length === 0) {
451
+ const project = this.slotDb.getProjectById(input.scopeUserId, input.scopeAgentId, input.projectId);
452
+ if (project?.repo_root) {
453
+ paths = this.collectGitTrackedPaths(project.repo_root);
454
+ }
455
+ }
456
+ if (paths.length === 0)
457
+ return;
458
+ this.slotDb.reindexProjectByDiff(input.scopeUserId, input.scopeAgentId, {
459
+ project_id: input.projectId,
460
+ source_rev: input.sourceRev,
461
+ trigger_type: input.triggerType,
462
+ index_profile: input.indexProfile,
463
+ paths,
464
+ });
465
+ }
466
+ catch {
467
+ // Background-friendly fire-and-forget path: do not block foreground tool response.
468
+ }
469
+ }, 0);
470
+ }
471
+ resolveProjectRef(scopeUserId, scopeAgentId, projectRef) {
472
+ if (projectRef?.project_id) {
473
+ const project = this.slotDb.getProjectById(scopeUserId, scopeAgentId, projectRef.project_id);
474
+ if (!project)
475
+ throw new Error(`project_id '${projectRef.project_id}' is not registered`);
476
+ return project;
477
+ }
478
+ if (projectRef?.project_alias) {
479
+ const byAlias = this.slotDb.getProjectByAlias(scopeUserId, scopeAgentId, projectRef.project_alias);
480
+ if (!byAlias)
481
+ throw new Error(`project_alias '${projectRef.project_alias}' is not registered`);
482
+ return byAlias.project;
483
+ }
484
+ throw new Error("project reference requires project_id or project_alias");
485
+ }
486
+ validateJiraTrackerFields(trackerSpaceKey, defaultEpicKey) {
487
+ const space = String(trackerSpaceKey || "").trim();
488
+ if (!space) {
489
+ throw new Error("jira tracker requires tracker_space_key");
490
+ }
491
+ if (!/^[A-Z][A-Z0-9_]*$/.test(space)) {
492
+ throw new Error("jira tracker_space_key format is invalid");
493
+ }
494
+ const epic = String(defaultEpicKey || "").trim();
495
+ if (epic) {
496
+ const expectedPrefix = `${space}-`;
497
+ if (!epic.toUpperCase().startsWith(expectedPrefix)) {
498
+ throw new Error(`default_epic_key must match tracker_space_key prefix '${space}-*'`);
499
+ }
500
+ }
501
+ }
502
+ tryResolveRepoRootFromCwd() {
503
+ try {
504
+ const output = execSync("git rev-parse --show-toplevel", {
505
+ stdio: ["ignore", "pipe", "ignore"],
506
+ encoding: "utf8",
507
+ });
508
+ const value = String(output || "").trim();
509
+ return value || undefined;
510
+ }
511
+ catch {
512
+ return undefined;
513
+ }
514
+ }
515
+ resolveWorkspaceRoot(req) {
516
+ const candidates = [
517
+ process.env.AGENT_MEMO_PROJECT_WORKSPACE_ROOT,
518
+ process.env.AGENT_MEMO_REPO_CLONE_ROOT,
519
+ process.env.PROJECT_WORKSPACE_ROOT,
520
+ process.env.REPO_CLONE_ROOT,
521
+ req?.meta?.projectWorkspaceRoot,
522
+ req?.meta?.repoCloneRoot,
523
+ req?.context?.metadata?.projectWorkspaceRoot,
524
+ req?.context?.metadata?.repoCloneRoot,
525
+ req?.context?.metadata?.workspaceRoot,
526
+ ];
527
+ for (const raw of candidates) {
528
+ const value = String(raw || "").trim();
529
+ if (!value)
530
+ continue;
531
+ const resolved = isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value);
532
+ try {
533
+ mkdirSync(resolved, { recursive: true });
534
+ }
535
+ catch {
536
+ // ignore and fallback
537
+ }
538
+ if (existsSync(resolved))
539
+ return resolved;
540
+ }
541
+ const fallback = resolve(process.env.HOME || process.cwd(), ".openclaw", "workspace", "projects");
542
+ mkdirSync(fallback, { recursive: true });
543
+ return fallback;
544
+ }
545
+ resolveRepoForRegistration(scopeUserId, scopeAgentId, input) {
546
+ const notes = [];
547
+ const explicitRepoRoot = this.normalizeRepoRootInput(input.explicitRepoRoot);
548
+ if (explicitRepoRoot) {
549
+ notes.push("repo_root provided explicitly by operator/request payload");
550
+ return {
551
+ repo_root: explicitRepoRoot,
552
+ repo_remote: this.tryReadGitRemote(explicitRepoRoot) || input.repoUrl,
553
+ resolution: "explicit_repo_root",
554
+ clone_policy: "not_applicable",
555
+ workspace_root: input.workspaceRoot,
556
+ notes,
557
+ };
558
+ }
559
+ const cwdRoot = this.tryResolveRepoRootFromCwd();
560
+ const normalizedRepoUrl = this.normalizeRepoUrl(input.repoUrl);
561
+ if (cwdRoot) {
562
+ const cwdRemote = this.tryReadGitRemote(cwdRoot);
563
+ const cwdMatchesRepoUrl = !normalizedRepoUrl || !cwdRemote || this.canonicalizeRemote(cwdRemote) === this.canonicalizeRemote(normalizedRepoUrl);
564
+ if (cwdMatchesRepoUrl) {
565
+ notes.push("resolved repo_root from current git working directory");
566
+ return {
567
+ repo_root: cwdRoot,
568
+ repo_remote: cwdRemote || normalizedRepoUrl,
569
+ resolution: "cwd_git_root",
570
+ clone_policy: "not_applicable",
571
+ workspace_root: input.workspaceRoot,
572
+ notes,
573
+ };
574
+ }
575
+ notes.push("current git root exists but remote does not match repo_url; skipped cwd reuse");
576
+ }
577
+ if (normalizedRepoUrl) {
578
+ const registered = this.findRegisteredProjectByRemote(scopeUserId, scopeAgentId, normalizedRepoUrl);
579
+ if (registered?.repo_root) {
580
+ notes.push("matched existing registered project by repo remote; reusing repo_root");
581
+ return {
582
+ repo_root: registered.repo_root,
583
+ repo_remote: registered.repo_remote_primary || normalizedRepoUrl,
584
+ resolution: "registered_remote_match",
585
+ clone_policy: "reuse_existing_clone",
586
+ workspace_root: input.workspaceRoot,
587
+ clone_target: registered.repo_root,
588
+ notes,
589
+ };
590
+ }
591
+ const imported = this.tryResolveLocalPathImport(normalizedRepoUrl, input.workspaceRoot);
592
+ if (imported) {
593
+ notes.push("repo_url points to a local path; imported without git clone");
594
+ return {
595
+ repo_root: imported,
596
+ repo_remote: this.tryReadGitRemote(imported) || normalizedRepoUrl,
597
+ resolution: "imported_local_path",
598
+ clone_policy: "not_applicable",
599
+ workspace_root: input.workspaceRoot,
600
+ clone_target: imported,
601
+ notes,
602
+ };
603
+ }
604
+ const cloneAttempt = this.cloneOrReuseRepo(normalizedRepoUrl, input.workspaceRoot);
605
+ notes.push(...cloneAttempt.notes);
606
+ return {
607
+ repo_root: cloneAttempt.repo_root,
608
+ repo_remote: cloneAttempt.repo_remote,
609
+ resolution: "cloned_from_repo_url",
610
+ clone_policy: cloneAttempt.clone_policy,
611
+ workspace_root: input.workspaceRoot,
612
+ clone_target: cloneAttempt.clone_target,
613
+ notes,
614
+ };
615
+ }
616
+ notes.push("repo root unresolved: no explicit repo_root, not inside git repo, and no repo_url provided");
617
+ return {
618
+ repo_root: undefined,
619
+ repo_remote: undefined,
620
+ resolution: "repo_root_missing",
621
+ clone_policy: "not_applicable",
622
+ workspace_root: input.workspaceRoot,
623
+ notes,
624
+ };
625
+ }
626
+ normalizeRepoRootInput(repoRoot) {
627
+ const raw = String(repoRoot || "").trim();
628
+ if (!raw)
629
+ return undefined;
630
+ const resolved = isAbsolute(raw) ? resolve(raw) : resolve(process.cwd(), raw);
631
+ if (!existsSync(resolved))
632
+ return resolved;
633
+ try {
634
+ if (statSync(resolved).isDirectory())
635
+ return resolved;
636
+ }
637
+ catch {
638
+ // ignore
639
+ }
640
+ return resolved;
641
+ }
642
+ normalizeRepoUrl(repoUrl) {
643
+ const value = String(repoUrl || "").trim();
644
+ return value || undefined;
645
+ }
646
+ canonicalizeRemote(remote) {
647
+ const value = String(remote || "").trim();
648
+ if (!value)
649
+ return undefined;
650
+ if (value.startsWith("git@")) {
651
+ const stripped = value.replace(/^git@/, "").replace(":", "/");
652
+ return stripped.toLowerCase().replace(/\.git$/i, "");
653
+ }
654
+ return value
655
+ .replace(/^https?:\/\//i, "")
656
+ .replace(/^ssh:\/\//i, "")
657
+ .toLowerCase()
658
+ .replace(/\.git$/i, "");
659
+ }
660
+ findRegisteredProjectByRemote(scopeUserId, scopeAgentId, repoUrl) {
661
+ const canonical = this.canonicalizeRemote(repoUrl);
662
+ if (!canonical)
663
+ return null;
664
+ const projects = this.slotDb.listProjects(scopeUserId, scopeAgentId);
665
+ for (const row of projects) {
666
+ const remote = row.project.repo_remote_primary;
667
+ if (!remote)
668
+ continue;
669
+ if (this.canonicalizeRemote(remote) === canonical) {
670
+ return {
671
+ repo_root: row.project.repo_root,
672
+ repo_remote_primary: row.project.repo_remote_primary,
673
+ };
674
+ }
675
+ }
676
+ return null;
677
+ }
678
+ deriveRepoFolderName(repoUrl) {
679
+ const cleaned = repoUrl.replace(/[#?].*$/, "").replace(/\/+$/, "");
680
+ const base = cleaned.split(/[/:]/).pop() || "repo";
681
+ const name = base.replace(/\.git$/i, "").trim() || "repo";
682
+ return name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "repo";
683
+ }
684
+ tryResolveLocalPathImport(repoUrl, workspaceRoot) {
685
+ const looksLocal = repoUrl.startsWith("/") || repoUrl.startsWith("./") || repoUrl.startsWith("../") || repoUrl.startsWith("file://");
686
+ if (!looksLocal)
687
+ return undefined;
688
+ const rawPath = repoUrl.startsWith("file://") ? repoUrl.slice("file://".length) : repoUrl;
689
+ const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(workspaceRoot, rawPath);
690
+ if (!existsSync(candidate))
691
+ return undefined;
692
+ try {
693
+ if (statSync(candidate).isDirectory()) {
694
+ return candidate;
695
+ }
696
+ }
697
+ catch {
698
+ return undefined;
699
+ }
700
+ return undefined;
701
+ }
702
+ cloneOrReuseRepo(repoUrl, workspaceRoot) {
703
+ const notes = [];
704
+ mkdirSync(workspaceRoot, { recursive: true });
705
+ const folderName = this.deriveRepoFolderName(repoUrl);
706
+ const preferredTarget = resolve(workspaceRoot, folderName);
707
+ if (this.isMatchingExistingClone(preferredTarget, repoUrl)) {
708
+ notes.push(`existing clone already present at ${preferredTarget}; reused`);
709
+ return {
710
+ repo_root: preferredTarget,
711
+ repo_remote: this.tryReadGitRemote(preferredTarget) || repoUrl,
712
+ clone_policy: "reuse_existing_clone",
713
+ clone_target: preferredTarget,
714
+ notes,
715
+ };
716
+ }
717
+ if (!existsSync(preferredTarget)) {
718
+ this.gitClone(repoUrl, preferredTarget);
719
+ notes.push(`cloned repo_url into workspace root at ${preferredTarget}`);
720
+ return {
721
+ repo_root: preferredTarget,
722
+ repo_remote: this.tryReadGitRemote(preferredTarget) || repoUrl,
723
+ clone_policy: "cloned_new",
724
+ clone_target: preferredTarget,
725
+ notes,
726
+ };
727
+ }
728
+ const conflictTarget = this.computeConflictCloneTarget(preferredTarget, repoUrl);
729
+ this.gitClone(repoUrl, conflictTarget);
730
+ notes.push(`preferred clone target occupied; cloned with suffix at ${conflictTarget}`);
731
+ return {
732
+ repo_root: conflictTarget,
733
+ repo_remote: this.tryReadGitRemote(conflictTarget) || repoUrl,
734
+ clone_policy: "cloned_to_conflict_suffix",
735
+ clone_target: conflictTarget,
736
+ notes,
737
+ };
738
+ }
739
+ computeConflictCloneTarget(preferredTarget, repoUrl) {
740
+ const canonical = this.canonicalizeRemote(repoUrl) || repoUrl;
741
+ const digest = createHash("sha1").update(canonical).digest("hex").slice(0, 8);
742
+ const parent = dirname(preferredTarget);
743
+ const base = basename(preferredTarget);
744
+ let candidate = resolve(parent, `${base}--${digest}`);
745
+ let index = 1;
746
+ while (existsSync(candidate)) {
747
+ if (this.isMatchingExistingClone(candidate, repoUrl)) {
748
+ return candidate;
749
+ }
750
+ candidate = resolve(parent, `${base}--${digest}-${index}`);
751
+ index += 1;
752
+ }
753
+ return candidate;
754
+ }
755
+ isMatchingExistingClone(targetDir, repoUrl) {
756
+ if (!existsSync(targetDir))
757
+ return false;
758
+ try {
759
+ if (!statSync(targetDir).isDirectory())
760
+ return false;
761
+ }
762
+ catch {
763
+ return false;
764
+ }
765
+ if (!existsSync(resolve(targetDir, ".git")))
766
+ return false;
767
+ const existingRemote = this.tryReadGitRemote(targetDir);
768
+ if (!existingRemote)
769
+ return false;
770
+ return this.canonicalizeRemote(existingRemote) === this.canonicalizeRemote(repoUrl);
771
+ }
772
+ gitClone(repoUrl, targetDir) {
773
+ execSync(`git clone ${shellEscape(repoUrl)} ${shellEscape(targetDir)}`, {
774
+ stdio: ["ignore", "pipe", "pipe"],
775
+ encoding: "utf8",
776
+ });
777
+ }
778
+ tryReadGitRemote(repoRoot) {
779
+ try {
780
+ const output = execSync("git remote get-url origin", {
781
+ cwd: repoRoot,
782
+ stdio: ["ignore", "pipe", "ignore"],
783
+ encoding: "utf8",
784
+ });
785
+ const value = String(output || "").trim();
786
+ return value || undefined;
787
+ }
788
+ catch {
789
+ return undefined;
790
+ }
791
+ }
792
+ collectGitTrackedPaths(repoRoot) {
793
+ try {
794
+ const output = execSync("git ls-files", {
795
+ cwd: repoRoot,
796
+ stdio: ["ignore", "pipe", "ignore"],
797
+ encoding: "utf8",
798
+ });
799
+ const paths = String(output || "")
800
+ .split("\n")
801
+ .map((line) => line.trim())
802
+ .filter(Boolean);
803
+ return paths
804
+ .filter((p) => !p.startsWith(".git/"))
805
+ .map((relativePath) => {
806
+ const ext = relativePath.includes(".") ? relativePath.split(".").pop() || "" : "";
807
+ return {
808
+ relative_path: relativePath,
809
+ checksum: `git:${relativePath}`,
810
+ module: relativePath.split("/")[0] || undefined,
811
+ language: ext || undefined,
812
+ };
813
+ });
814
+ }
815
+ catch {
816
+ // fallback for non-git local import
817
+ const files = [];
818
+ const walk = (dir, prefix = "") => {
819
+ const entries = readdirSync(dir, { withFileTypes: true });
820
+ for (const entry of entries) {
821
+ if (entry.name === ".git" || entry.name === "node_modules")
822
+ continue;
823
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
824
+ const abs = resolve(dir, entry.name);
825
+ if (entry.isDirectory()) {
826
+ walk(abs, rel);
827
+ continue;
828
+ }
829
+ files.push(rel);
830
+ }
831
+ };
832
+ try {
833
+ walk(repoRoot);
834
+ }
835
+ catch {
836
+ return [];
837
+ }
838
+ return files.map((relativePath) => ({
839
+ relative_path: relativePath,
840
+ checksum: `fs:${relativePath}`,
841
+ module: relativePath.split("/")[0] || undefined,
842
+ language: relativePath.includes(".") ? relativePath.split(".").pop() || undefined : undefined,
843
+ }));
844
+ }
845
+ }
846
+ handleProjectReindexDiff(payload, req) {
847
+ const identity = normalizePrivateIdentity(req.context);
848
+ if (!payload.project_id) {
849
+ throw new Error("project.reindex_diff requires payload.project_id");
850
+ }
851
+ return this.slotDb.reindexProjectByDiff(identity.userId, identity.agentId, {
852
+ project_id: payload.project_id,
853
+ source_rev: payload.source_rev,
854
+ trigger_type: payload.trigger_type,
855
+ index_profile: payload.index_profile,
856
+ paths: payload.paths || [],
857
+ });
858
+ }
859
+ handleProjectIndexWatchGet(payload, req) {
860
+ const identity = normalizePrivateIdentity(req.context);
861
+ if (!payload.project_id) {
862
+ throw new Error("project.index_watch_get requires payload.project_id");
863
+ }
864
+ return this.slotDb.getProjectIndexWatchState(identity.userId, identity.agentId, payload.project_id);
865
+ }
866
+ handleProjectTaskRegistryUpsert(payload, req) {
867
+ const identity = normalizePrivateIdentity(req.context);
868
+ if (!payload.task_id || !payload.project_id || !payload.task_title) {
869
+ throw new Error("project.task_registry_upsert requires payload.task_id, payload.project_id, payload.task_title");
870
+ }
871
+ return this.slotDb.upsertTaskRegistryRecord(identity.userId, identity.agentId, {
872
+ task_id: payload.task_id,
873
+ project_id: payload.project_id,
874
+ task_title: payload.task_title,
875
+ task_type: payload.task_type,
876
+ task_status: payload.task_status,
877
+ parent_task_id: payload.parent_task_id,
878
+ related_task_ids: payload.related_task_ids || [],
879
+ files_touched: payload.files_touched || [],
880
+ symbols_touched: payload.symbols_touched || [],
881
+ commit_refs: payload.commit_refs || [],
882
+ diff_refs: payload.diff_refs || [],
883
+ decision_notes: payload.decision_notes ?? null,
884
+ tracker_issue_key: payload.tracker_issue_key ?? null,
885
+ });
886
+ }
887
+ handleProjectTaskLineageContext(payload, req) {
888
+ const identity = normalizePrivateIdentity(req.context);
889
+ if (!payload.project_id) {
890
+ throw new Error("project.task_lineage_context requires payload.project_id");
891
+ }
892
+ if (!payload.task_id && !payload.tracker_issue_key && !payload.task_title) {
893
+ throw new Error("project.task_lineage_context requires one selector: task_id|tracker_issue_key|task_title");
894
+ }
895
+ return this.slotDb.getTaskLineageContext(identity.userId, identity.agentId, {
896
+ project_id: payload.project_id,
897
+ task_id: payload.task_id,
898
+ tracker_issue_key: payload.tracker_issue_key,
899
+ task_title: payload.task_title,
900
+ include_related: payload.include_related,
901
+ include_parent_chain: payload.include_parent_chain,
902
+ });
903
+ }
904
+ handleProjectHybridSearch(payload, req) {
905
+ const identity = normalizePrivateIdentity(req.context);
906
+ if (!payload.project_id || !payload.query) {
907
+ throw new Error("project.hybrid_search requires payload.project_id and payload.query");
908
+ }
909
+ return this.slotDb.hybridSearchProjectContext(identity.userId, identity.agentId, {
910
+ project_id: payload.project_id,
911
+ query: payload.query,
912
+ limit: payload.limit,
913
+ path_prefix: payload.path_prefix || [],
914
+ module: payload.module || [],
915
+ language: payload.language || [],
916
+ task_id: payload.task_id || [],
917
+ tracker_issue_key: payload.tracker_issue_key || [],
918
+ task_context: payload.task_context,
919
+ });
920
+ }
921
+ handleProjectLegacyBackfill(payload, req) {
922
+ const identity = normalizePrivateIdentity(req.context);
923
+ return this.slotDb.runLegacyCompatibilityBackfill(identity.userId, identity.agentId, {
924
+ mode: payload.mode || "dry_run",
925
+ only_project_ids: payload.only_project_ids || [],
926
+ only_aliases: payload.only_aliases || [],
927
+ force_registration_state: payload.force_registration_state === true,
928
+ source: payload.source || "mixed",
929
+ });
930
+ }
931
+ handleProjectTelegramOnboarding(payload, req) {
932
+ const mode = payload.mode || "preview";
933
+ const workspaceRoot = this.normalizeRepoRootInput(payload.project_workspace_root) || this.resolveWorkspaceRoot(req);
934
+ const draft = {
935
+ command: String(payload.command || "").trim() || "/project",
936
+ repo_url: String(payload.repo_url || "").trim(),
937
+ project_alias: String(payload.project_alias || "").trim(),
938
+ jira_space_key: String(payload.jira_space_key || "").trim().toUpperCase(),
939
+ default_epic_key: String(payload.default_epic_key || "").trim().toUpperCase(),
940
+ index_now: payload.index_now === true,
941
+ project_name: String(payload.project_name || "").trim() || undefined,
942
+ repo_root: String(payload.repo_root || "").trim() || undefined,
943
+ active_version: String(payload.active_version || "").trim() || undefined,
944
+ project_workspace_root: workspaceRoot,
945
+ };
946
+ const errors = [];
947
+ const warnings = [];
948
+ if (!draft.repo_url && !draft.repo_root) {
949
+ errors.push("repo_url or repo_root is required");
950
+ }
951
+ if (!draft.project_alias) {
952
+ warnings.push("project_alias is empty; alias should be confirmed before final commit");
953
+ }
954
+ if (draft.jira_space_key) {
955
+ if (!/^[A-Z][A-Z0-9_]*$/.test(draft.jira_space_key)) {
956
+ errors.push("jira_space_key format is invalid");
957
+ }
958
+ if (draft.default_epic_key) {
959
+ const expectedPrefix = `${draft.jira_space_key}-`;
960
+ if (!draft.default_epic_key.startsWith(expectedPrefix)) {
961
+ errors.push(`default_epic_key must match jira_space_key prefix '${draft.jira_space_key}-*'`);
962
+ }
963
+ }
964
+ }
965
+ else if (draft.default_epic_key) {
966
+ errors.push("jira_space_key is required when default_epic_key is provided");
967
+ }
968
+ const selectionPreview = this.resolveRepoForRegistration(normalizePrivateIdentity(req.context).userId, normalizePrivateIdentity(req.context).agentId, {
969
+ explicitRepoRoot: draft.repo_root,
970
+ repoUrl: draft.repo_url || undefined,
971
+ workspaceRoot,
972
+ });
973
+ const summaryCard = {
974
+ title: "Project onboarding preview",
975
+ fields: {
976
+ command: draft.command,
977
+ repo_url: draft.repo_url || null,
978
+ repo_root: selectionPreview.repo_root || null,
979
+ project_alias: draft.project_alias || null,
980
+ jira_space_key: draft.jira_space_key || null,
981
+ default_epic_key: draft.default_epic_key || null,
982
+ index_now: draft.index_now,
983
+ project_workspace_root: workspaceRoot,
984
+ repo_resolution: selectionPreview.resolution,
985
+ clone_policy: selectionPreview.clone_policy || "not_applicable",
986
+ },
987
+ actions: ["confirm", "edit_alias", "edit_jira", "index_now", "cancel"],
988
+ notes: selectionPreview.notes,
989
+ };
990
+ if (mode !== "confirm") {
991
+ return {
992
+ status: errors.length > 0 ? "validation_error" : "preview_ready",
993
+ errors,
994
+ warnings,
995
+ summary_card: summaryCard,
996
+ bridge_commands: {
997
+ register: "project.register_command",
998
+ link_tracker: "project.link_tracker",
999
+ trigger_index: "project.trigger_index",
1000
+ },
1001
+ };
1002
+ }
1003
+ if (errors.length > 0) {
1004
+ return {
1005
+ status: "validation_error",
1006
+ errors,
1007
+ warnings,
1008
+ summary_card: summaryCard,
1009
+ };
1010
+ }
1011
+ const registerPayload = {
1012
+ project_alias: draft.project_alias,
1013
+ project_name: draft.project_name,
1014
+ repo_root: draft.repo_root,
1015
+ repo_remote: draft.repo_url || undefined,
1016
+ repo_url: draft.repo_url || undefined,
1017
+ active_version: draft.active_version,
1018
+ options: {
1019
+ trigger_index: draft.index_now,
1020
+ },
1021
+ tracker: draft.jira_space_key
1022
+ ? {
1023
+ tracker_type: "jira",
1024
+ tracker_space_key: draft.jira_space_key,
1025
+ default_epic_key: draft.default_epic_key || undefined,
1026
+ active_version: draft.active_version,
1027
+ }
1028
+ : undefined,
1029
+ };
1030
+ const registered = this.handleProjectRegisterCommand(registerPayload, req);
1031
+ return {
1032
+ status: "committed",
1033
+ project_id: registered.project_id,
1034
+ project_alias: registered.project_alias,
1035
+ tracker_mapping: registered.tracker_mapping,
1036
+ repo_resolution: registered.repo_resolution,
1037
+ index_trigger: registered.index_trigger,
1038
+ warnings,
1039
+ used_commands: ["project.register_command", "project.link_tracker", "project.trigger_index"],
1040
+ };
1041
+ }
169
1042
  handleGraphEntityGet(payload, req) {
170
1043
  const identity = normalizePrivateIdentity(req.context);
171
1044
  if (payload.id) {