nexusmem 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
- import pc21 from "picocolors";
5
+ import pc22 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -155,7 +155,26 @@ var ConfigSchema = z.object({
155
155
  */
156
156
  structure: z.object({
157
157
  enabled: z.boolean().default(true)
158
- }).default({ enabled: true })
158
+ }).default({ enabled: true }),
159
+ /**
160
+ * Issue and PR threads (title, body, comments) for this repo's
161
+ * github.com remote, read via the `gh` CLI.
162
+ *
163
+ * Opt-in, unlike git/shell/docs/structure -- not for a sensitivity
164
+ * reason (issue/PR text is usually no more sensitive than a doc file),
165
+ * but because this is the first source with a real external dependency:
166
+ * it needs `gh` installed and authenticated, and makes live network
167
+ * calls instead of reading only what's already on disk. A repo with no
168
+ * github.com remote at all, or an unauthenticated `gh`, must stay a
169
+ * silent no-op either way -- see `syncGithub` in sync.ts.
170
+ */
171
+ github: z.object({
172
+ enabled: z.boolean().default(false),
173
+ /** Threads read per sync, newest-updated first. */
174
+ maxThreads: z.number().int().positive().default(100),
175
+ /** Comments read per thread. */
176
+ maxCommentsPerThread: z.number().int().positive().default(100)
177
+ }).default({ enabled: false, maxThreads: 100, maxCommentsPerThread: 100 })
159
178
  }).default({
160
179
  git: { enabled: true, since: null, includeMerges: true },
161
180
  shell: { enabled: true, tailLines: 300 },
@@ -169,7 +188,8 @@ var ConfigSchema = z.object({
169
188
  },
170
189
  docs: { enabled: true, include: ["*.md"] },
171
190
  diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 },
172
- structure: { enabled: true }
191
+ structure: { enabled: true },
192
+ github: { enabled: false, maxThreads: 100, maxCommentsPerThread: 100 }
173
193
  }),
174
194
  limits: z.object({
175
195
  maxFilesPerNode: z.number().int().positive().default(40),
@@ -1013,6 +1033,10 @@ INSERT INTO nodes_vec (rowid, project_id, embedding)
1013
1033
 
1014
1034
  DROP TABLE nodes_vec_stage;
1015
1035
  `;
1036
+ var V12 = `
1037
+ ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
1038
+ ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
1039
+ `;
1016
1040
  var MIGRATIONS = [
1017
1041
  { version: 1, up: (db) => db.exec(V1) },
1018
1042
  { version: 2, up: (db) => db.exec(V2) },
@@ -1024,7 +1048,8 @@ var MIGRATIONS = [
1024
1048
  { version: 8, up: (db) => db.exec(V8) },
1025
1049
  { version: 9, up: (db) => db.exec(V9) },
1026
1050
  { version: 10, up: (db) => db.exec(V10) },
1027
- { version: 11, up: (db) => db.exec(V11) }
1051
+ { version: 11, up: (db) => db.exec(V11) },
1052
+ { version: 12, up: (db) => db.exec(V12) }
1028
1053
  ];
1029
1054
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
1030
1055
  function currentSchemaVersion(db) {
@@ -1123,6 +1148,8 @@ function defaultProvenanceForKind(kind) {
1123
1148
  return "recorded";
1124
1149
  case "session_summary":
1125
1150
  return "derived";
1151
+ case "github_thread":
1152
+ return "recorded";
1126
1153
  }
1127
1154
  }
1128
1155
 
@@ -1294,6 +1321,19 @@ function listStaleCandidates(db, projectId, opts = {}) {
1294
1321
  ageDays: Math.round((now.getTime() - r.tsEpoch) / 864e5)
1295
1322
  }));
1296
1323
  }
1324
+ function recordRetrievals(db, ids) {
1325
+ if (ids.length === 0) return;
1326
+ db.prepare(
1327
+ `UPDATE nodes SET retrieved_count = retrieved_count + 1, last_retrieved_at = @now
1328
+ WHERE id IN (SELECT value FROM json_each(@ids))`
1329
+ ).run({ now: Date.now(), ids: JSON.stringify(ids) });
1330
+ }
1331
+ function getRetrievalStats(db, id) {
1332
+ const row = db.prepare("SELECT retrieved_count AS retrievedCount, last_retrieved_at AS lastRetrievedAt FROM nodes WHERE id = ?").get(
1333
+ id
1334
+ );
1335
+ return row ?? null;
1336
+ }
1297
1337
  function countStaleCandidates(db, projectId, opts = {}) {
1298
1338
  const now = opts.now ?? /* @__PURE__ */ new Date();
1299
1339
  const minAgeDays = opts.minAgeDays ?? 45;
@@ -1823,6 +1863,14 @@ var MemoryStore = class _MemoryStore {
1823
1863
  countStaleCandidates(projectId, opts = {}) {
1824
1864
  return countStaleCandidates(this.db, projectId, opts);
1825
1865
  }
1866
+ /** Bump retrieval bookkeeping for nodes actually packed into a query result. See `nodes.ts`'s `recordRetrievals` for why this doesn't touch ranking. */
1867
+ recordRetrievals(ids) {
1868
+ recordRetrievals(this.db, ids);
1869
+ }
1870
+ /** Retrieval bookkeeping for one node, or null if it doesn't exist. Mainly for tests. */
1871
+ getRetrievalStats(id) {
1872
+ return getRetrievalStats(this.db, id);
1873
+ }
1826
1874
  /** Memoize one SLM contradiction judgment (either verdict). Suggest-only: never writes `supersedes`. */
1827
1875
  recordContradictionCheck(input) {
1828
1876
  recordContradictionCheck(this.db, input);
@@ -2790,8 +2838,21 @@ async function runCrossProjectQuery(sources, query, opts) {
2790
2838
  rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
2791
2839
  );
2792
2840
  const packed = packContext(ranked, opts.budget, { query });
2841
+ recordRetrievalsByProject(storeByLabel, packed.nodes);
2793
2842
  return { bm25Count, vectorCount, hits, packed, perProject };
2794
2843
  }
2844
+ function recordRetrievalsByProject(storeByLabel, nodes) {
2845
+ const idsByLabel = /* @__PURE__ */ new Map();
2846
+ for (const node of nodes) {
2847
+ if (!node.project) continue;
2848
+ const ids = idsByLabel.get(node.project) ?? [];
2849
+ ids.push(node.id);
2850
+ idsByLabel.set(node.project, ids);
2851
+ }
2852
+ for (const [label, ids] of idsByLabel) {
2853
+ storeByLabel.get(label)?.recordRetrievals(ids);
2854
+ }
2855
+ }
2795
2856
  async function runHybridQuery(store, projectId, query, opts) {
2796
2857
  const bm25Hits = store.search(projectId, query, opts.candidates, { asOfEpoch: opts.asOfEpoch });
2797
2858
  let vectorHits = [];
@@ -2807,6 +2868,7 @@ async function runHybridQuery(store, projectId, query, opts) {
2807
2868
  rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
2808
2869
  );
2809
2870
  const packed = packContext(ranked, opts.budget, { query });
2871
+ store.recordRetrievals(packed.nodes.map((n) => n.id));
2810
2872
  return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
2811
2873
  }
2812
2874
 
@@ -3634,6 +3696,63 @@ function collectDocFiles(files, projectId, opts = {}) {
3634
3696
  return files.flatMap((file) => toMemoryNodes3(file, projectId, opts));
3635
3697
  }
3636
3698
 
3699
+ // src/collectors/github.ts
3700
+ var NOTABLE_LABELS = /\b(bug|help wanted|good first issue)\b/i;
3701
+ function scoreGithubThread(thread) {
3702
+ let score = 0.4;
3703
+ if (thread.merged) score += 0.2;
3704
+ else if (thread.state === "closed" && thread.comments.length === 0) score -= 0.15;
3705
+ if (thread.comments.length >= 3) score += 0.15;
3706
+ else if (thread.comments.length >= 1) score += 0.05;
3707
+ if (thread.labels.some((l) => NOTABLE_LABELS.test(l))) score += 0.1;
3708
+ return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
3709
+ }
3710
+ var MAX_TITLE_CHARS5 = 200;
3711
+ function threadTitle(thread) {
3712
+ const prefix = thread.type === "pull_request" ? "PR" : "Issue";
3713
+ return truncate(`${prefix} #${thread.number}: ${thread.title}`, MAX_TITLE_CHARS5);
3714
+ }
3715
+ function renderBody(thread) {
3716
+ const status = thread.merged ? "merged" : thread.state;
3717
+ const header = `${thread.title}
3718
+ opened by @${thread.author}, ${status}`;
3719
+ const opening = thread.body.trim();
3720
+ const commentsText = thread.comments.map((c) => `--- @${c.author} (${c.createdAt.slice(0, 10)}) ---
3721
+ ${c.body.trim()}`).join("\n\n");
3722
+ return [header, opening, commentsText].filter(Boolean).join("\n\n");
3723
+ }
3724
+ var DEFAULT_MAX_BODY_CHARS3 = 4e3;
3725
+ function toMemoryNode2(thread, projectId, opts = {}) {
3726
+ const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS3;
3727
+ const naturalKey = `${thread.type}:${thread.number}`;
3728
+ const source = thread.type === "pull_request" ? "github:pr" : "github:issue";
3729
+ return {
3730
+ id: makeNodeId(projectId, "github_thread", naturalKey),
3731
+ kind: "github_thread",
3732
+ projectId,
3733
+ ts: thread.updatedAt,
3734
+ source,
3735
+ title: threadTitle(thread),
3736
+ body: truncate(renderBody(thread), maxBody),
3737
+ files: [],
3738
+ signal: scoreGithubThread(thread),
3739
+ provenance: "recorded",
3740
+ // verbatim discourse, same tier as conversation_turn
3741
+ meta: {
3742
+ number: thread.number,
3743
+ type: thread.type,
3744
+ state: thread.state,
3745
+ merged: thread.merged,
3746
+ labels: thread.labels,
3747
+ url: thread.url,
3748
+ commentCount: thread.comments.length
3749
+ }
3750
+ };
3751
+ }
3752
+ function collectGithubThreads(threads, projectId, opts = {}) {
3753
+ return threads.map((thread) => toMemoryNode2(thread, projectId, opts));
3754
+ }
3755
+
3637
3756
  // src/slm/summarize.ts
3638
3757
  var MAX_USER_CHARS = 400;
3639
3758
  var MAX_REPLY_CHARS = 700;
@@ -3725,7 +3844,7 @@ function sessionFallbackTitle(session) {
3725
3844
  const line = redact(opening.userText).text.split(/\r?\n/)[0]?.trim();
3726
3845
  return line && line.length > 0 ? line : "Working session";
3727
3846
  }
3728
- var MAX_TITLE_CHARS5 = 200;
3847
+ var MAX_TITLE_CHARS6 = 200;
3729
3848
  var GENERIC_TITLE = /^(a |the )?(session |conversation |project |work )?(summary|update|overview|recap|status)\b/i;
3730
3849
  var ROLE_PREAMBLE_TITLE = /^(role\s*[::]|you\s*(?:'re|are)\s+(acting as|serving as|playing the role of|a\b)|i\s*(?:'m|am)\s+(acting as|a\b)|acting as\b|บทบาท\s*[::]|ในฐานะ|คุณ(กำลัง)?(ทำหน้าที่เป็น|เป็น|รับบทบาทเป็น)|(ผม|ฉัน)(กำลัง)?(ทำหน้าที่เป็น|เป็น))/i;
3731
3850
  function cleanTitle(line) {
@@ -3739,12 +3858,12 @@ function parseSummary(raw, fallbackTitle) {
3739
3858
  if (firstIndex === -1) return null;
3740
3859
  const first = lines[firstIndex].trim();
3741
3860
  const labelled = /^TITLE:\s*(.+)$/i.exec(first);
3742
- const fallback = truncate(cleanTitle(fallbackTitle) || "Working session", MAX_TITLE_CHARS5);
3861
+ const fallback = truncate(cleanTitle(fallbackTitle) || "Working session", MAX_TITLE_CHARS6);
3743
3862
  if (!labelled) return { title: fallback, body: text };
3744
3863
  const candidate = cleanTitle(labelled[1]);
3745
3864
  const rest = lines.slice(firstIndex + 1).join("\n").trim();
3746
3865
  return {
3747
- title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) && !ROLE_PREAMBLE_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
3866
+ title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) && !ROLE_PREAMBLE_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS6) : fallback,
3748
3867
  // A model that emitted only a title still gets a usable node: the title
3749
3868
  // doubles as the body rather than storing an empty one.
3750
3869
  body: rest.length > 0 ? rest : candidate
@@ -3754,7 +3873,7 @@ function parseSummary(raw, fallbackTitle) {
3754
3873
  // src/collectors/sessions.ts
3755
3874
  var SESSION_SOURCE_PREFIX = "session";
3756
3875
  var DEFAULT_SETTLE_MINUTES = 30;
3757
- var DEFAULT_MAX_BODY_CHARS3 = 2500;
3876
+ var DEFAULT_MAX_BODY_CHARS4 = 2500;
3758
3877
  var DEFAULT_MAX_SESSIONS = 10;
3759
3878
  function scoreSession(turnCount) {
3760
3879
  const score = 0.6 + Math.min(0.25, turnCount / 100);
@@ -3798,7 +3917,7 @@ ${t.assistantText}`).join("\n")),
3798
3917
  };
3799
3918
  }
3800
3919
  async function collectSessionSummaries(turns, projectId, provider, opts = {}) {
3801
- const maxBodyChars = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS3;
3920
+ const maxBodyChars = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS4;
3802
3921
  const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
3803
3922
  const all = groupTurnsIntoSessions(turns);
3804
3923
  const settled = selectSettledSessions(all, opts.settleMinutes ?? DEFAULT_SETTLE_MINUTES, opts.now);
@@ -3846,8 +3965,8 @@ async function collectSessionSummaries(turns, projectId, provider, opts = {}) {
3846
3965
  }
3847
3966
 
3848
3967
  // src/collectors/shell-history.ts
3849
- var DEFAULT_MAX_BODY_CHARS4 = 1e3;
3850
- var MAX_TITLE_CHARS6 = 200;
3968
+ var DEFAULT_MAX_BODY_CHARS5 = 1e3;
3969
+ var MAX_TITLE_CHARS7 = 200;
3851
3970
  var NOISE = /^(cd|ls|dir|pwd|clear|cls|exit|history|whoami|date|type|cat|more|less|ll|la)\b/i;
3852
3971
  var BUILD_TEST = /^(npm|pnpm|yarn)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|^(pytest|go\s+test|cargo\s+(test|build)|mvn\s+test|gradle\s+test|dotnet\s+(test|build))\b/i;
3853
3972
  var INSTALL = /^(npm|pnpm|yarn)\s+(install|add|remove|uninstall|ci)\b|^pip\s+install\b|^(cargo\s+add|go\s+get|composer\s+require)\b/i;
@@ -3870,7 +3989,7 @@ function scoreShellCommand(entry) {
3870
3989
  else if (cmd.length <= 3) score -= 0.05;
3871
3990
  return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
3872
3991
  }
3873
- function renderBody(entry, maxChars) {
3992
+ function renderBody2(entry, maxChars) {
3874
3993
  const parts = [`$ ${entry.command}`];
3875
3994
  const metaLine = [];
3876
3995
  if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
@@ -3879,8 +3998,8 @@ function renderBody(entry, maxChars) {
3879
3998
  if (metaLine.length) parts.push("", metaLine.join(" "));
3880
3999
  return truncate(parts.join("\n"), maxChars);
3881
4000
  }
3882
- function toMemoryNode2(entry, projectId, opts = {}) {
3883
- const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS4;
4001
+ function toMemoryNode3(entry, projectId, opts = {}) {
4002
+ const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS5;
3884
4003
  const titleLine = entry.command.split(/\r?\n/)[0] ?? entry.command;
3885
4004
  return {
3886
4005
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
@@ -3888,8 +4007,8 @@ function toMemoryNode2(entry, projectId, opts = {}) {
3888
4007
  projectId,
3889
4008
  ts: entry.ts,
3890
4009
  source: `shell:${entry.shell}`,
3891
- title: truncate(titleLine, MAX_TITLE_CHARS6),
3892
- body: renderBody(entry, maxBody),
4010
+ title: truncate(titleLine, MAX_TITLE_CHARS7),
4011
+ body: renderBody2(entry, maxBody),
3893
4012
  files: [],
3894
4013
  signal: scoreShellCommand(entry),
3895
4014
  provenance: "observed",
@@ -3904,7 +4023,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
3904
4023
  };
3905
4024
  }
3906
4025
  function collectShellHistory(entries, projectId, opts = {}) {
3907
- return entries.map((entry) => toMemoryNode2(entry, projectId, opts));
4026
+ return entries.map((entry) => toMemoryNode3(entry, projectId, opts));
3908
4027
  }
3909
4028
 
3910
4029
  // src/conversation/claude-code-reader.ts
@@ -4036,6 +4155,112 @@ async function readDocFiles(repoRoot, opts = {}) {
4036
4155
  return { files, unreadable };
4037
4156
  }
4038
4157
 
4158
+ // src/github/read.ts
4159
+ import { execFile as execFile2 } from "child_process";
4160
+ import { promisify as promisify2 } from "util";
4161
+ var execFileAsync2 = promisify2(execFile2);
4162
+ var GithubUnavailableError = class extends Error {
4163
+ constructor(message, cause) {
4164
+ super(message);
4165
+ this.cause = cause;
4166
+ this.name = "GithubUnavailableError";
4167
+ }
4168
+ cause;
4169
+ };
4170
+ function toGithubError(err) {
4171
+ const code = err?.code;
4172
+ if (code === "ENOENT") {
4173
+ return new GithubUnavailableError(
4174
+ "gh CLI not found on PATH -- install it from https://cli.github.com and run `gh auth login`",
4175
+ err
4176
+ );
4177
+ }
4178
+ const stderr = String(err?.stderr ?? "");
4179
+ if (/auth login|not logged into|401/i.test(stderr)) {
4180
+ return new GithubUnavailableError("gh CLI is not authenticated -- run `gh auth login`", err);
4181
+ }
4182
+ const detail = stderr.trim().split("\n")[0] || (err instanceof Error ? err.message : String(err));
4183
+ return new GithubUnavailableError(`gh api call failed: ${detail}`, err);
4184
+ }
4185
+ function parseGithubSlug(originUrl) {
4186
+ if (!originUrl) return null;
4187
+ const match = originUrl.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
4188
+ if (!match) return null;
4189
+ return `${match[1]}/${match[2]}`;
4190
+ }
4191
+ var GhCliProvider = class {
4192
+ constructor(repoSlug, opts = {}) {
4193
+ this.repoSlug = repoSlug;
4194
+ this.opts = opts;
4195
+ }
4196
+ repoSlug;
4197
+ opts;
4198
+ async listThreads(since) {
4199
+ const maxThreads = this.opts.maxThreads ?? 100;
4200
+ const perPage = Math.min(maxThreads, 100);
4201
+ const params = new URLSearchParams({
4202
+ state: "all",
4203
+ sort: "updated",
4204
+ direction: "desc",
4205
+ per_page: String(perPage)
4206
+ });
4207
+ if (since) params.set("since", since);
4208
+ let items;
4209
+ try {
4210
+ const { stdout } = await execFileAsync2("gh", ["api", `repos/${this.repoSlug}/issues?${params}`], {
4211
+ maxBuffer: 16 * 1024 * 1024
4212
+ });
4213
+ items = JSON.parse(stdout);
4214
+ } catch (err) {
4215
+ throw toGithubError(err);
4216
+ }
4217
+ const bounded = items.slice(0, maxThreads);
4218
+ const threads = [];
4219
+ for (const item of bounded) {
4220
+ const comments = item.comments > 0 ? await this.fetchComments(item.number) : [];
4221
+ threads.push({
4222
+ number: item.number,
4223
+ type: item.pull_request ? "pull_request" : "issue",
4224
+ title: item.title,
4225
+ body: item.body ?? "",
4226
+ author: item.user?.login ?? "unknown",
4227
+ state: item.state,
4228
+ merged: item.pull_request?.merged_at != null,
4229
+ labels: item.labels.map((l) => typeof l === "string" ? l : l.name),
4230
+ createdAt: item.created_at,
4231
+ updatedAt: item.updated_at,
4232
+ url: item.html_url,
4233
+ comments
4234
+ });
4235
+ }
4236
+ return threads;
4237
+ }
4238
+ /**
4239
+ * Fails soft per thread, unlike `listThreads` itself: one comment fetch
4240
+ * failing (a deleted comment, a transient network blip) shouldn't drop the
4241
+ * whole sync the way an unreachable `gh`/no auth does. The thread still
4242
+ * gets ingested, just without that page of discussion this run.
4243
+ */
4244
+ async fetchComments(number) {
4245
+ const maxComments = this.opts.maxCommentsPerThread ?? 100;
4246
+ try {
4247
+ const { stdout } = await execFileAsync2(
4248
+ "gh",
4249
+ ["api", `repos/${this.repoSlug}/issues/${number}/comments?per_page=${Math.min(maxComments, 100)}`],
4250
+ { maxBuffer: 16 * 1024 * 1024 }
4251
+ );
4252
+ const items = JSON.parse(stdout);
4253
+ return items.slice(0, maxComments).map((c) => ({
4254
+ author: c.user?.login ?? "unknown",
4255
+ body: c.body ?? "",
4256
+ createdAt: c.created_at
4257
+ }));
4258
+ } catch {
4259
+ return [];
4260
+ }
4261
+ }
4262
+ };
4263
+
4039
4264
  // src/slm/contradiction.ts
4040
4265
  var MAX_BODY_CHARS = 1500;
4041
4266
  var MAX_REASON_CHARS = 200;
@@ -5190,6 +5415,38 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
5190
5415
  }
5191
5416
  return { totals, seen: nodes.length };
5192
5417
  }
5418
+ var GITHUB_SOURCE = "github";
5419
+ async function syncGithub(store, projectId, opts, repo, config, log) {
5420
+ const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
5421
+ const enabled = opts.githubOverride ?? config.sources.github.enabled;
5422
+ if (!enabled) return { totals, seen: 0 };
5423
+ const slug = parseGithubSlug(repo.originUrl);
5424
+ if (!slug) {
5425
+ log(`${pc7.dim("github")} no github.com remote found -- skipped`);
5426
+ return { totals, seen: 0 };
5427
+ }
5428
+ const provider = opts.githubProvider ?? new GhCliProvider(slug, {
5429
+ maxThreads: config.sources.github.maxThreads,
5430
+ maxCommentsPerThread: config.sources.github.maxCommentsPerThread
5431
+ });
5432
+ const cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GITHUB_SOURCE);
5433
+ const requestedAt = (/* @__PURE__ */ new Date()).toISOString();
5434
+ let threads;
5435
+ try {
5436
+ threads = await provider.listThreads(cursor);
5437
+ } catch (err) {
5438
+ if (err instanceof GithubUnavailableError) {
5439
+ log(`${pc7.dim("github")} ${err.message} -- skipped`);
5440
+ return { totals, seen: 0 };
5441
+ }
5442
+ throw err;
5443
+ }
5444
+ const nodes = collectGithubThreads(threads, projectId, { maxBodyChars: config.limits.maxBodyChars });
5445
+ if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
5446
+ store.setSyncCursor(projectId, GITHUB_SOURCE, requestedAt);
5447
+ log(` ${pc7.dim(`${GITHUB_SOURCE}: ${nodes.length} thread(s) read from ${slug}`)}`);
5448
+ return { totals, seen: nodes.length };
5449
+ }
5193
5450
  async function syncStructure(store, projectId, repoRoot, config, log) {
5194
5451
  if (!config.sources.structure.enabled) {
5195
5452
  log(`${pc7.dim("structure")} disabled in config`);
@@ -5312,6 +5569,7 @@ async function runSync(opts) {
5312
5569
  const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
5313
5570
  const sessions = await syncSessions(store, projectId, turns, config, log);
5314
5571
  const docs = await syncDocs(store, projectId, repo.root, config, log);
5572
+ const github = await syncGithub(store, projectId, opts, repo, config, log);
5315
5573
  const structure = await syncStructure(store, projectId, repo.root, config, log);
5316
5574
  let embedLine = "";
5317
5575
  let embeddingAvailable = false;
@@ -5354,6 +5612,7 @@ async function runSync(opts) {
5354
5612
  addStats(totals, conversation.totals);
5355
5613
  addStats(totals, sessions.totals);
5356
5614
  addStats(totals, docs.totals);
5615
+ addStats(totals, github.totals);
5357
5616
  const stats2 = store.stats(projectId);
5358
5617
  const elapsed = ((Date.now() - started) / 1e3).toFixed(2);
5359
5618
  const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
@@ -5361,10 +5620,12 @@ async function runSync(opts) {
5361
5620
  const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
5362
5621
  const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
5363
5622
  const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
5623
+ const githubEnabled = opts.githubOverride ?? config.sources.github.enabled;
5624
+ const githubPart = githubEnabled ? `, ${github.seen} github thread(s)` : "";
5364
5625
  const deniedPart = totals.denied > 0 ? ` ${pc7.red(`-${totals.denied} denied`)}` : "";
5365
5626
  out(
5366
5627
  [
5367
- `${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
5628
+ `${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${githubPart}${structurePart} in ${elapsed}s`,
5368
5629
  ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
5369
5630
  ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
5370
5631
  ""
@@ -5482,7 +5743,7 @@ function createServer() {
5482
5743
  "search_memory",
5483
5744
  {
5484
5745
  title: "Search remembered project history",
5485
- description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts and per-session summaries. Returns a token-budgeted, ranked context block -- not raw search results.",
5746
+ description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts, per-session summaries, and github.com issue/PR threads. Returns a token-budgeted, ranked context block -- not raw search results.",
5486
5747
  inputSchema: {
5487
5748
  projectRoot: z4.string().describe("Absolute path to the repository root"),
5488
5749
  query: z4.string().describe("Free-text question or search terms"),
@@ -5515,7 +5776,7 @@ function createServer() {
5515
5776
  "sync_project",
5516
5777
  {
5517
5778
  title: "Sync remembered history",
5518
- description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database. Pass pruneSource or pruneStaleShell instead to delete a dead source's nodes (e.g. the pre-hook shell scrape) rather than syncing -- dry-run unless yes is also true, since this is an irreversible full wipe of that source.",
5779
+ description: "Ingest new git, diff, shell, docs and (if enabled) conversation and github.com issue/PR history for a NexusMem-tracked repository into its local database. Pass pruneSource or pruneStaleShell instead to delete a dead source's nodes (e.g. the pre-hook shell scrape) rather than syncing -- dry-run unless yes is also true, since this is an irreversible full wipe of that source.",
5519
5780
  inputSchema: {
5520
5781
  projectRoot: z4.string().describe("Absolute path to the repository root"),
5521
5782
  pruneSource: z4.string().optional().describe('Delete every node from this exact source (e.g. "shell:pwsh") instead of syncing'),
@@ -5549,7 +5810,7 @@ function createServer() {
5549
5810
  "list_recent_memory",
5550
5811
  {
5551
5812
  title: "List recently remembered items",
5552
- description: "List the most recently remembered items for a NexusMem-tracked repository -- git commits, code diffs, shell commands, tracked docs, and (if enabled) conversation transcripts and session summaries -- newest first. Chronological, not relevance-ranked: use search_memory instead for a specific question.",
5813
+ description: "List the most recently remembered items for a NexusMem-tracked repository -- git commits, code diffs, shell commands, tracked docs, and (if enabled) conversation transcripts, session summaries, and github.com issue/PR threads -- newest first. Chronological, not relevance-ranked: use search_memory instead for a specific question.",
5553
5814
  inputSchema: {
5554
5815
  projectRoot: z4.string().describe("Absolute path to the repository root"),
5555
5816
  limit: z4.number().int().positive().optional().describe("Max items to return, newest first. Default 20.")
@@ -5655,7 +5916,7 @@ ${pc8.dim("-".repeat(40))}
5655
5916
  out(` ${pc8.bold(risk.path)}
5656
5917
  `);
5657
5918
  if (risk.unresolvedFailures.length > 0) {
5658
- out(` ${pc8.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
5919
+ out(` ${pc8.yellow("WARN")} commands naming this file failed, still unresolved (${risk.unresolvedFailures.length}):
5659
5920
  `);
5660
5921
  for (const f of risk.unresolvedFailures.slice(0, 3)) {
5661
5922
  out(` ${pc8.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
@@ -5667,7 +5928,7 @@ ${pc8.dim("-".repeat(40))}
5667
5928
  }
5668
5929
  }
5669
5930
  if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
5670
- out(` ${pc8.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
5931
+ out(` ${pc8.dim("note")} high churn (untuned heuristic): ${risk.commitsRecent} commits touched this file recently
5671
5932
  `);
5672
5933
  }
5673
5934
  out("\n");
@@ -5822,6 +6083,7 @@ var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
5822
6083
  var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
5823
6084
  var DOCS_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
5824
6085
  var DIFF_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
6086
+ var GITHUB_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
5825
6087
  function signalBand(signal, bands) {
5826
6088
  if (signal >= bands.high) return "high";
5827
6089
  if (signal >= bands.medium) return "medium";
@@ -6029,14 +6291,57 @@ function formatNode4(node) {
6029
6291
  ].join(" ");
6030
6292
  }
6031
6293
 
6032
- // src/cli/commands/scan-session.ts
6294
+ // src/cli/commands/scan-github.ts
6033
6295
  import pc16 from "picocolors";
6296
+ async function runScanGithub(opts) {
6297
+ const repo = await readRepoInfo(opts.cwd);
6298
+ const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
6299
+ const slug = parseGithubSlug(repo.originUrl);
6300
+ if (!slug) {
6301
+ process.stderr.write(`${pc16.yellow("no github.com remote found for this repository")}
6302
+ `);
6303
+ return 0;
6304
+ }
6305
+ const provider = opts.provider ?? new GhCliProvider(slug);
6306
+ let threads;
6307
+ try {
6308
+ threads = await provider.listThreads(null);
6309
+ } catch (err) {
6310
+ if (err instanceof GithubUnavailableError) {
6311
+ process.stderr.write(`${pc16.yellow(err.message)}
6312
+ `);
6313
+ return 0;
6314
+ }
6315
+ throw err;
6316
+ }
6317
+ const nodes = collectGithubThreads(threads, projectId).filter((n) => n.signal >= opts.minSignal);
6318
+ if (opts.json) {
6319
+ process.stdout.write(`${JSON.stringify(nodes, null, 2)}
6320
+ `);
6321
+ return 0;
6322
+ }
6323
+ for (const node of nodes) process.stdout.write(`${formatNode5(node)}
6324
+ `);
6325
+ const approxTotal = approxTotalTokens(nodes);
6326
+ process.stderr.write(
6327
+ `
6328
+ ${pc16.bold(String(nodes.length))} thread(s) from ${pc16.dim(slug)} above threshold ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6329
+ `
6330
+ );
6331
+ return 0;
6332
+ }
6333
+ function formatNode5(node) {
6334
+ return [formatSignal(node.signal, GITHUB_SIGNAL_BANDS), node.title].join(" ");
6335
+ }
6336
+
6337
+ // src/cli/commands/scan-session.ts
6338
+ import pc17 from "picocolors";
6034
6339
  async function runScanSession(opts) {
6035
6340
  const repo = await readRepoInfo(opts.cwd);
6036
6341
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
6037
6342
  const turns = await collectClaudeCodeTranscripts(repo.root);
6038
6343
  if (turns.length === 0) {
6039
- process.stderr.write(`${pc16.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6344
+ process.stderr.write(`${pc17.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6040
6345
  `);
6041
6346
  return 0;
6042
6347
  }
@@ -6044,7 +6349,7 @@ async function runScanSession(opts) {
6044
6349
  const settled = selectSettledSessions(sessions, opts.settleMinutes);
6045
6350
  if (!opts.json) {
6046
6351
  process.stderr.write(
6047
- `${pc16.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
6352
+ `${pc17.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
6048
6353
 
6049
6354
  `
6050
6355
  );
@@ -6070,7 +6375,7 @@ async function runScanSession(opts) {
6070
6375
  }
6071
6376
  for (const preview of previews) {
6072
6377
  process.stdout.write(
6073
- `${pc16.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
6378
+ `${pc17.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
6074
6379
  ${preview.prompt}
6075
6380
 
6076
6381
  `
@@ -6082,7 +6387,7 @@ ${preview.prompt}
6082
6387
  settleMinutes: opts.settleMinutes,
6083
6388
  maxSessions: opts.maxSessions,
6084
6389
  onProgress: (done, total) => {
6085
- if (!opts.json) process.stderr.write(` ${pc16.dim(`summarizing ${done}/${total}`)}
6390
+ if (!opts.json) process.stderr.write(` ${pc17.dim(`summarizing ${done}/${total}`)}
6086
6391
  `);
6087
6392
  }
6088
6393
  });
@@ -6092,21 +6397,21 @@ ${preview.prompt}
6092
6397
  return 0;
6093
6398
  }
6094
6399
  for (const node of result.nodes) {
6095
- process.stdout.write(`${pc16.bold(node.title)}
6096
- ${pc16.dim(node.ts.slice(0, 16).replace("T", " "))}
6400
+ process.stdout.write(`${pc17.bold(node.title)}
6401
+ ${pc17.dim(node.ts.slice(0, 16).replace("T", " "))}
6097
6402
  ${node.body}
6098
6403
 
6099
6404
  `);
6100
6405
  }
6101
6406
  if (result.providerUnavailable) {
6102
6407
  process.stderr.write(
6103
- `${pc16.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
6408
+ `${pc17.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
6104
6409
  `
6105
6410
  );
6106
6411
  return 0;
6107
6412
  }
6108
6413
  process.stderr.write(
6109
- `${pc16.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc16.yellow(`${result.failed} failed`)}` : "") + ` ${pc16.dim(`(model ${opts.model})`)}
6414
+ `${pc17.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc17.yellow(`${result.failed} failed`)}` : "") + ` ${pc17.dim(`(model ${opts.model})`)}
6110
6415
  `
6111
6416
  );
6112
6417
  return 0;
@@ -6114,16 +6419,16 @@ ${node.body}
6114
6419
  var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
6115
6420
 
6116
6421
  // src/cli/commands/scan-shell.ts
6117
- import pc17 from "picocolors";
6422
+ import pc18 from "picocolors";
6118
6423
  async function runScanShell(opts) {
6119
6424
  const repo = await readRepoInfo(opts.cwd);
6120
6425
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
6121
6426
  const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
6122
6427
  if (!opts.json) {
6123
6428
  process.stderr.write(
6124
- results.length ? `${pc17.dim("sources found")} ${results.map((r) => r.name).join(", ")}
6429
+ results.length ? `${pc18.dim("sources found")} ${results.map((r) => r.name).join(", ")}
6125
6430
 
6126
- ` : `${pc17.yellow("no shell history source found on this machine")}
6431
+ ` : `${pc18.yellow("no shell history source found on this machine")}
6127
6432
  `
6128
6433
  );
6129
6434
  }
@@ -6132,9 +6437,9 @@ async function runScanShell(opts) {
6132
6437
  const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
6133
6438
  allNodes.push(...nodes);
6134
6439
  if (!opts.json) {
6135
- process.stdout.write(`${pc17.bold(`shell:${result.name}`)} ${pc17.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
6440
+ process.stdout.write(`${pc18.bold(`shell:${result.name}`)} ${pc18.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
6136
6441
  `);
6137
- for (const node of nodes) process.stdout.write(`${formatNode5(node)}
6442
+ for (const node of nodes) process.stdout.write(`${formatNode6(node)}
6138
6443
  `);
6139
6444
  process.stdout.write("\n");
6140
6445
  }
@@ -6145,19 +6450,19 @@ async function runScanShell(opts) {
6145
6450
  return 0;
6146
6451
  }
6147
6452
  const approxTotal = approxTotalTokens(allNodes);
6148
- process.stderr.write(`${pc17.bold(String(allNodes.length))} node(s) total ${pc17.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6453
+ process.stderr.write(`${pc18.bold(String(allNodes.length))} node(s) total ${pc18.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6149
6454
  `);
6150
6455
  return 0;
6151
6456
  }
6152
- function formatNode5(node) {
6153
- const approx = node.meta.tsApprox ? pc17.dim("~") : " ";
6457
+ function formatNode6(node) {
6458
+ const approx = node.meta.tsApprox ? pc18.dim("~") : " ";
6154
6459
  const exit = node.meta.exitCode;
6155
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc17.red(`exit ${exit}`) : "";
6460
+ const exitLabel = typeof exit === "number" && exit !== 0 ? pc18.red(`exit ${exit}`) : "";
6156
6461
  return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
6157
6462
  }
6158
6463
 
6159
6464
  // src/cli/commands/scan-structure.ts
6160
- import pc18 from "picocolors";
6465
+ import pc19 from "picocolors";
6161
6466
  var TRACKED_EXTENSIONS = TRACKED_PATHSPECS.map((p) => p.replace("*", "")).join("/");
6162
6467
  async function runScanStructure(opts) {
6163
6468
  const repo = await readRepoInfo(opts.cwd);
@@ -6168,24 +6473,24 @@ async function runScanStructure(opts) {
6168
6473
  return 0;
6169
6474
  }
6170
6475
  if (unreadable.length > 0) {
6171
- process.stderr.write(`${pc18.yellow("unreadable")} ${unreadable.join(", ")}
6476
+ process.stderr.write(`${pc19.yellow("unreadable")} ${unreadable.join(", ")}
6172
6477
 
6173
6478
  `);
6174
6479
  }
6175
6480
  for (const edge of edges) {
6176
- process.stdout.write(`${edge.fromPath} ${pc18.dim("->")} ${edge.toPath}
6481
+ process.stdout.write(`${edge.fromPath} ${pc19.dim("->")} ${edge.toPath}
6177
6482
  `);
6178
6483
  }
6179
6484
  process.stderr.write(
6180
6485
  `
6181
- ${pc18.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6486
+ ${pc19.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKED_EXTENSIONS} file(s)
6182
6487
  `
6183
6488
  );
6184
6489
  return 0;
6185
6490
  }
6186
6491
 
6187
6492
  // src/cli/commands/stale.ts
6188
- import pc19 from "picocolors";
6493
+ import pc20 from "picocolors";
6189
6494
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
6190
6495
  async function runStale(opts) {
6191
6496
  const { projectId, ws } = await loadContext(opts.cwd);
@@ -6195,15 +6500,15 @@ async function runStale(opts) {
6195
6500
  if (opts.dismiss) {
6196
6501
  const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
6197
6502
  out(
6198
- dismissed > 0 ? `${pc19.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6199
- ` : `${pc19.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
6503
+ dismissed > 0 ? `${pc20.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6504
+ ` : `${pc20.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
6200
6505
  `
6201
6506
  );
6202
6507
  return 0;
6203
6508
  }
6204
6509
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
6205
6510
  if (candidates.length === 0) {
6206
- out(`${pc19.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6511
+ out(`${pc20.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
6207
6512
  `);
6208
6513
  return 0;
6209
6514
  }
@@ -6225,15 +6530,15 @@ async function runStale(opts) {
6225
6530
  for (const s of suggestions) byCandidateId.set(s.candidateId, s);
6226
6531
  out(
6227
6532
  [
6228
- `${pc19.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6533
+ `${pc20.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
6229
6534
  ...candidates.map((c) => {
6230
- const line = ` ${pc19.dim(c.id)} ${pc19.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6535
+ const line = ` ${pc20.dim(c.id)} ${pc20.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
6231
6536
  const hit = byCandidateId.get(c.id);
6232
6537
  return hit ? `${line}
6233
- ${pc19.red("likely superseded by")} ${pc19.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6538
+ ${pc20.red("likely superseded by")} ${pc20.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
6234
6539
  }),
6235
6540
  "",
6236
- `run ${pc19.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6541
+ `run ${pc20.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
6237
6542
  ].join("\n").concat("\n")
6238
6543
  );
6239
6544
  return 0;
@@ -6245,7 +6550,7 @@ async function runStale(opts) {
6245
6550
  // src/cli/commands/status.ts
6246
6551
  import { basename as basename4 } from "path";
6247
6552
  import { statSync } from "fs";
6248
- import pc20 from "picocolors";
6553
+ import pc21 from "picocolors";
6249
6554
  function daySpan(oldest, newest) {
6250
6555
  const oldestDay = Date.parse(oldest.slice(0, 10));
6251
6556
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -6305,34 +6610,34 @@ async function runStatus(opts) {
6305
6610
  const flaggedCount = store.countContradictionSuggestions(projectId);
6306
6611
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6307
6612
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
6308
- const staleProjectWarning = otherProjectIds.length ? `${pc20.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc20.bold(
6613
+ const staleProjectWarning = otherProjectIds.length ? `${pc21.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc21.bold(
6309
6614
  "nexusmem sync --prune-source <name>"
6310
6615
  )} to remove stale source data` : "";
6311
6616
  out(
6312
6617
  [
6313
- `${pc20.dim("repo ")} ${repo.root}`,
6314
- `${pc20.dim("branch ")} ${repo.branch ?? pc20.yellow("(detached)")}`,
6315
- `${pc20.dim("project ")} ${pc20.cyan(projectId)}`,
6316
- `${pc20.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc20.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6317
- `${pc20.dim("database")} ${ws.dbPath} ${pc20.dim(`(${humanBytes(dbBytes)})`)}`,
6618
+ `${pc21.dim("repo ")} ${repo.root}`,
6619
+ `${pc21.dim("branch ")} ${repo.branch ?? pc21.yellow("(detached)")}`,
6620
+ `${pc21.dim("project ")} ${pc21.cyan(projectId)}`,
6621
+ `${pc21.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc21.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6622
+ `${pc21.dim("database")} ${ws.dbPath} ${pc21.dim(`(${humanBytes(dbBytes)})`)}`,
6318
6623
  staleProjectWarning,
6319
6624
  "",
6320
- `${pc20.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc20.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6625
+ `${pc21.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc21.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
6321
6626
  ...kinds,
6322
- stats2.total ? ` ${pc20.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6627
+ stats2.total ? ` ${pc21.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6323
6628
  "",
6324
- sources.length ? pc20.dim("sources") : pc20.yellow("no sources synced yet"),
6629
+ sources.length ? pc21.dim("sources") : pc21.yellow("no sources synced yet"),
6325
6630
  ...sources.map((s) => {
6326
6631
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
6327
6632
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
6328
- return ` ${s.source.padEnd(14)} ${pc20.dim(`last run ${when}`)} ${pc20.dim(`cursor ${cursorLabel}`)}`;
6633
+ return ` ${s.source.padEnd(14)} ${pc21.dim(`last run ${when}`)} ${pc21.dim(`cursor ${cursorLabel}`)}`;
6329
6634
  }),
6330
6635
  "",
6331
- gitCursor && gitCursor !== repo.head ? `${pc20.yellow("git behind HEAD")} \u2014 run ${pc20.bold("nexusmem sync")}` : "",
6332
- chains.failuresTotal ? `${pc20.dim("chains ")} ${pc20.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc20.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc20.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6333
- structure.edges ? `${pc20.dim("structure")} ${pc20.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6334
- staleCount ? `${pc20.dim("aging ")} ${pc20.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc20.bold("nexusmem stale")}` : "",
6335
- flaggedCount ? `${pc20.dim("flagged ")} ${pc20.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc20.bold("nexusmem stale")} for detail` : ""
6636
+ gitCursor && gitCursor !== repo.head ? `${pc21.yellow("git behind HEAD")} \u2014 run ${pc21.bold("nexusmem sync")}` : "",
6637
+ chains.failuresTotal ? `${pc21.dim("chains ")} ${pc21.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc21.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc21.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
6638
+ structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6639
+ staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
6640
+ flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : ""
6336
6641
  ].filter((line) => line !== "").join("\n").concat("\n")
6337
6642
  );
6338
6643
  return 0;
@@ -6355,7 +6660,7 @@ function guard(run) {
6355
6660
  process.exitCode = await run();
6356
6661
  } catch (err) {
6357
6662
  if (isExpected(err)) {
6358
- process.stderr.write(`${pc21.red("error")} ${err.message}
6663
+ process.stderr.write(`${pc22.red("error")} ${err.message}
6359
6664
  `);
6360
6665
  process.exitCode = 1;
6361
6666
  return;
@@ -6371,7 +6676,7 @@ program.command("init").description("Create the .nexusmem workspace and database
6371
6676
  () => runInit({ cwd: options.cwd, force: options.force, hook: options.hook, enableConversation: options.enableConversation })
6372
6677
  )()
6373
6678
  );
6374
- program.command("sync").description("Ingest new history into the local database").option("-C, --cwd <path>", "repository path", process.cwd()).option("--full", "ignore the stored cursor and re-walk all history", false).option("--rebuild", "drop this project's nodes and re-ingest from scratch", false).option("--since <date>", "override the configured git cutoff, e.g. 1.year.ago").option("--shell-lines <count>", "override the configured shell tail-window size", (v) => Number.parseInt(v, 10)).option("--conversation", "force the conversation source on for this run, without persisting it to config", false).option("--no-embed", "skip the vector-embedding pass for this run").option(
6679
+ program.command("sync").description("Ingest new history into the local database").option("-C, --cwd <path>", "repository path", process.cwd()).option("--full", "ignore the stored cursor and re-walk all history", false).option("--rebuild", "drop this project's nodes and re-ingest from scratch", false).option("--since <date>", "override the configured git cutoff, e.g. 1.year.ago").option("--shell-lines <count>", "override the configured shell tail-window size", (v) => Number.parseInt(v, 10)).option("--conversation", "force the conversation source on for this run, without persisting it to config", false).option("--github", "force the github issue/PR source on for this run, without persisting it to config", false).option("--no-embed", "skip the vector-embedding pass for this run").option(
6375
6680
  "--embed-limit <count>",
6376
6681
  "stop embedding after this many nodes (default: embed everything pending)",
6377
6682
  (v) => Number.parseInt(v, 10)
@@ -6392,6 +6697,7 @@ program.command("sync").description("Ingest new history into the local database"
6392
6697
  since: options.since,
6393
6698
  shellTailLines: options.shellLines,
6394
6699
  conversationOverride: options.conversation ? true : void 0,
6700
+ githubOverride: options.github ? true : void 0,
6395
6701
  noEmbed: !options.embed,
6396
6702
  embedLimit: options.embedLimit,
6397
6703
  pruneSource: options.pruneSource,
@@ -6523,6 +6829,7 @@ program.command("scan-session").description("Preview the session summaries a loc
6523
6829
  )()
6524
6830
  );
6525
6831
  program.command("scan-docs").description("Preview the MemoryNodes tracked .md files would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action((options) => guard(() => runScanDocs({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))());
6832
+ program.command("scan-github").description("Preview the MemoryNodes github.com issues/PRs would produce (writes nothing, needs `gh` installed and authenticated)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action((options) => guard(() => runScanGithub({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))());
6526
6833
  program.command("precheck").description("Warn about staged files with unresolved past failures or high recent churn (advisory, exits 0 unless --strict)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--files <paths...>", "check exactly these repo-relative paths instead of what is staged").option("--working", "check the working tree (unstaged changes) instead of what is staged for commit", false).option("--strict", "exit 1 when any file has an unresolved failure", false).option("-q, --quiet", "only print output when there is something to warn about", false).action(
6527
6834
  (options) => guard(
6528
6835
  () => runPrecheck({
@@ -6538,7 +6845,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
6538
6845
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
6539
6846
  program.parseAsync(process.argv).catch((err) => {
6540
6847
  const message = err instanceof Error ? err.message : String(err);
6541
- process.stderr.write(`${pc21.red("error")} ${message}
6848
+ process.stderr.write(`${pc22.red("error")} ${message}
6542
6849
  `);
6543
6850
  process.exitCode = 1;
6544
6851
  });