threadnote 0.6.3 → 0.7.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.
@@ -3523,8 +3523,8 @@ var DEFAULT_SEED_PATTERNS = [
3523
3523
  ];
3524
3524
 
3525
3525
  // src/hooks.ts
3526
- var import_promises6 = require("node:fs/promises");
3527
- var import_node_path7 = require("node:path");
3526
+ var import_promises7 = require("node:fs/promises");
3527
+ var import_node_path8 = require("node:path");
3528
3528
 
3529
3529
  // src/mcp.ts
3530
3530
  var import_node_fs2 = require("node:fs");
@@ -4487,8 +4487,8 @@ function existsSyncDirectory(path) {
4487
4487
  }
4488
4488
 
4489
4489
  // src/memory.ts
4490
- var import_promises4 = require("node:fs/promises");
4491
- var import_node_path4 = require("node:path");
4490
+ var import_promises5 = require("node:fs/promises");
4491
+ var import_node_path5 = require("node:path");
4492
4492
 
4493
4493
  // src/manifest.ts
4494
4494
  var import_promises3 = require("node:fs/promises");
@@ -7230,2460 +7230,2711 @@ function renderTemplate(template, config) {
7230
7230
  return template.replaceAll("{{THREADNOTE_HOME}}", config.agentContextHome).replaceAll("{{OPENVIKING_ACCOUNT}}", config.account).replaceAll("{{OPENVIKING_AGENT_ID}}", config.agentId).replaceAll("{{OPENVIKING_HOST}}", config.host).replaceAll("{{OPENVIKING_PORT}}", String(config.port)).replaceAll("{{OPENVIKING_USER}}", config.user);
7231
7231
  }
7232
7232
 
7233
- // src/memory.ts
7234
- function parseMemoryKind(value) {
7235
- if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
7236
- return value;
7233
+ // src/share.ts
7234
+ var import_promises4 = require("node:fs/promises");
7235
+ var import_node_os4 = require("node:os");
7236
+ var import_node_path4 = require("node:path");
7237
+ var TEAMS_FILE_VERSION = 1;
7238
+ var SHARED_SEGMENT = "shared";
7239
+ var SHAREABLE_MEMORY_KIND_DIRS = ["durable"];
7240
+ var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
7241
+ var DEFAULT_GIT_REMOTE_NAME = "origin";
7242
+ var SCRUBBER_PATTERNS = [
7243
+ // Credentials: never redactable. Blocking is the only safe response —
7244
+ // automated redaction risks false negatives that leave material in git.
7245
+ { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
7246
+ { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
7247
+ { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
7248
+ { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
7249
+ { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
7250
+ { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
7251
+ // Matches bare JWTs (three base64url segments). May surface a JWE token in
7252
+ // legitimate docs; if that becomes noisy we can switch to warn-only.
7253
+ { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
7254
+ { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
7255
+ // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
7256
+ // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
7257
+ { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
7258
+ // Soft leaks: block by default (so the agent sees them and decides), but
7259
+ // allow opt-in redaction so curated memories with incidental matches can
7260
+ // ship without a manual rewrite. Local home paths are the recurring
7261
+ // real-world leak; the regexes greedily consume the whole path segment
7262
+ // (including subdirectories) up to whitespace or common closing punctuation
7263
+ // so redaction collapses an entire path to a single placeholder rather than
7264
+ // leaving the subpath visible.
7265
+ { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
7266
+ { name: "linux home path", placeholder: "<local-path>", regex: /\b\/home\/[^\s)>"'`,]+/ }
7267
+ ];
7268
+ function applyScrubber(content, { redact }) {
7269
+ let cleaned = content;
7270
+ const redactions = [];
7271
+ for (const pattern of SCRUBBER_PATTERNS) {
7272
+ if (!pattern.regex.test(cleaned)) {
7273
+ continue;
7274
+ }
7275
+ if (!pattern.placeholder || !redact) {
7276
+ return { blocker: pattern.name, cleaned: content, redactions: [] };
7277
+ }
7278
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
7279
+ const globalRegex = new RegExp(pattern.regex.source, flags);
7280
+ const matches = cleaned.match(globalRegex) ?? [];
7281
+ cleaned = cleaned.replace(globalRegex, pattern.placeholder);
7282
+ redactions.push({ count: matches.length, name: pattern.name });
7237
7283
  }
7238
- throw new Error(`Unsupported memory kind "${value}". Expected durable, handoff, incident, preference, or smoke.`);
7284
+ return { cleaned, redactions };
7239
7285
  }
7240
- function parseMemoryStatus(value) {
7241
- if (["active", "archived", "superseded"].includes(value)) {
7242
- return value;
7286
+ var autoShareStates = /* @__PURE__ */ new Map();
7287
+ async function runShareInit(config, remoteUrl, options) {
7288
+ if (!remoteUrl.trim()) {
7289
+ throw new Error("Provide a git remote URL for the shared memories repo.");
7243
7290
  }
7244
- throw new Error(`Unsupported memory status "${value}". Expected active, archived, or superseded.`);
7245
- }
7246
- async function runRemember(config, options) {
7247
- const text = await getInputText(options.text, options.stdin === true);
7248
- if (!text.trim()) {
7249
- throw new Error("Provide memory text with --text or --stdin.");
7291
+ const dryRun = options.dryRun === true;
7292
+ const teamName = normalizeTeamName(options.team);
7293
+ const teamsFile = await readTeamsFile(config);
7294
+ if (teamsFile.teams[teamName]) {
7295
+ throw new Error(
7296
+ `Team "${teamName}" is already configured (remote ${teamsFile.teams[teamName].remote}). Remove it first with: threadnote share remove --team ${teamName}`
7297
+ );
7250
7298
  }
7251
- const metadata = {
7252
- kind: options.kind ?? "durable",
7253
- project: normalizeOptionalMetadata(options.project),
7254
- sourceAgentClient: options.sourceAgentClient ?? "codex",
7255
- status: options.status ?? "active",
7256
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7257
- topic: normalizeOptionalMetadata(options.topic)
7299
+ const worktree = teamWorktreePath(config, teamName);
7300
+ const gitdir = teamGitdirPath(config, teamName);
7301
+ await assertWorktreeUsable(worktree);
7302
+ if (await exists(gitdir)) {
7303
+ throw new Error(`Gitdir already exists at ${gitdir}; remove it or pick a different team name.`);
7304
+ }
7305
+ await ensureDirectory((0, import_node_path4.dirname)(worktree), dryRun);
7306
+ await ensureDirectory((0, import_node_path4.dirname)(gitdir), dryRun);
7307
+ const git = await requiredExecutable("git");
7308
+ await maybeRun(dryRun, git, ["clone", `--separate-git-dir=${gitdir}`, "--", remoteUrl, worktree]);
7309
+ const newConfig = {
7310
+ addedAt: (/* @__PURE__ */ new Date()).toISOString(),
7311
+ gitdir,
7312
+ name: teamName,
7313
+ remote: remoteUrl,
7314
+ worktree
7258
7315
  };
7259
- await storeMemory(config, {
7260
- bodyText: text.trim(),
7261
- dryRun: options.dryRun === true,
7262
- metadata,
7263
- replaceUri: options.replace,
7264
- title: "MEMORY"
7265
- });
7316
+ const updatedTeams = {
7317
+ defaultTeam: shouldSetDefault(options, teamsFile) ? teamName : teamsFile.defaultTeam ?? teamName,
7318
+ teams: { ...teamsFile.teams, [teamName]: newConfig },
7319
+ version: TEAMS_FILE_VERSION
7320
+ };
7321
+ if (dryRun) {
7322
+ console.log(`Would write teams file: ${teamsFilePath(config)}`);
7323
+ console.log(`Would set ${teamName} as default? ${updatedTeams.defaultTeam === teamName}`);
7324
+ } else {
7325
+ await writeTeamsFile(config, updatedTeams);
7326
+ console.log(`Configured shared team "${teamName}" -> ${portablePath(worktree)}`);
7327
+ }
7328
+ if (!dryRun) {
7329
+ await ensureSharedGitignore(worktree, git, options.push !== false);
7330
+ const ingested = await ingestWorktreeFiles(config, newConfig, "create");
7331
+ console.log(`Ingested ${ingested} shared memory file(s) into OpenViking.`);
7332
+ }
7266
7333
  }
7267
- async function runMigrateMemories(config, options) {
7268
- const dryRun = options.dryRun === true;
7269
- const limit = options.limit ? parsePositiveInteger(options.limit, "migration limit") : void 0;
7270
- const sourceAccounts = await legacySourceAccounts(config, options);
7271
- if (sourceAccounts.length === 0) {
7272
- console.log("No local OpenViking accounts found to scan.");
7334
+ var SHARED_GITIGNORE_PATTERNS = ["**/.abstract.md", "**/.overview.md"];
7335
+ var SHARED_GITIGNORE_HEADER = "# Threadnote: ignore OpenViking-generated directory summaries.";
7336
+ async function ensureSharedGitignore(worktree, git, push) {
7337
+ const gitignorePath = (0, import_node_path4.join)(worktree, ".gitignore");
7338
+ const existing = await readFileIfExists(gitignorePath) ?? "";
7339
+ const lines = existing.split("\n").map((line) => line.trim());
7340
+ const missingPatterns = SHARED_GITIGNORE_PATTERNS.filter((pattern) => !lines.includes(pattern));
7341
+ if (missingPatterns.length === 0) {
7273
7342
  return;
7274
7343
  }
7275
- const candidates = await legacyMemoryCandidates(config, sourceAccounts);
7276
- const existingHashes = await existingDurableMemoryHashes(config);
7277
- const ov = await openVikingCliForMode(dryRun);
7278
- const migrationPath = (0, import_node_path4.join)(config.agentContextHome, "legacy-memory-migration.txt");
7279
- let duplicateCount = 0;
7280
- let migratedCount = 0;
7281
- let sensitiveCount = 0;
7282
- if (!dryRun && candidates.length > 0) {
7283
- await ensureDurableMemoryDirectory(ov, config);
7344
+ const hasHeader = lines.includes(SHARED_GITIGNORE_HEADER);
7345
+ const segments = [];
7346
+ if (existing.length > 0 && !existing.endsWith("\n")) {
7347
+ segments.push("\n");
7284
7348
  }
7285
- try {
7286
- for (const candidate of candidates) {
7287
- if (existingHashes.has(candidate.hash)) {
7288
- duplicateCount += 1;
7289
- continue;
7290
- }
7291
- if (existingHashes.has(candidate.comparableHash)) {
7292
- duplicateCount += 1;
7293
- continue;
7294
- }
7295
- const sensitiveReason = sensitiveMemoryReason(candidate.text);
7296
- if (sensitiveReason) {
7297
- sensitiveCount += 1;
7298
- console.log(
7299
- `SKIP ${legacySourceLabel(candidate)}: possible ${sensitiveReason}; inspect the source archive manually if needed.`
7349
+ if (existing.length > 0) {
7350
+ segments.push("\n");
7351
+ }
7352
+ if (!hasHeader) {
7353
+ segments.push(SHARED_GITIGNORE_HEADER, "\n");
7354
+ }
7355
+ segments.push(missingPatterns.join("\n"), "\n");
7356
+ await (0, import_promises4.writeFile)(gitignorePath, `${existing}${segments.join("")}`, { encoding: "utf8" });
7357
+ console.log(`Added ${missingPatterns.join(", ")} to ${portablePath(gitignorePath)}`);
7358
+ await maybeRun(false, git, ["-C", worktree, "add", ".gitignore"]);
7359
+ const commitResult = await runCommand(
7360
+ git,
7361
+ ["-C", worktree, "commit", "-m", "share: ignore OpenViking directory summaries"],
7362
+ { allowFailure: true }
7363
+ );
7364
+ if (commitResult.exitCode !== 0) {
7365
+ const detail = commitResult.stderr.trim() || commitResult.stdout.trim();
7366
+ if (!/nothing to commit|no changes added/i.test(detail)) {
7367
+ console.warn(
7368
+ `.gitignore housekeeping commit was rejected (${detail || "unknown"}); it will be retried on the next share sync.`
7369
+ );
7370
+ return;
7371
+ }
7372
+ }
7373
+ if (push) {
7374
+ await maybeRun(false, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
7375
+ }
7376
+ }
7377
+ async function runShareStatus(config, options) {
7378
+ const team = await resolveTeam(config, options.team);
7379
+ const git = await requiredExecutable("git");
7380
+ console.log(`Team: ${team.name}`);
7381
+ console.log(`Remote: ${team.config.remote}`);
7382
+ console.log(`Worktree: ${portablePath(team.config.worktree)}`);
7383
+ console.log(`Gitdir: ${portablePath(team.config.gitdir)}`);
7384
+ await maybeRun(options.dryRun === true, git, ["-C", team.config.worktree, "status", "--short", "--branch"]);
7385
+ await maybeRun(options.dryRun === true, git, ["-C", team.config.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
7386
+ allowFailure: true
7387
+ });
7388
+ const ahead = await gitOutput(team.config.worktree, ["rev-list", "--count", "@{u}..HEAD"], options.dryRun === true);
7389
+ const behind = await gitOutput(team.config.worktree, ["rev-list", "--count", "HEAD..@{u}"], options.dryRun === true);
7390
+ if (ahead !== void 0) {
7391
+ console.log(`Ahead of upstream: ${ahead}`);
7392
+ }
7393
+ if (behind !== void 0) {
7394
+ console.log(`Behind upstream: ${behind}`);
7395
+ }
7396
+ }
7397
+ async function syncSharedReposBeforeAgentRead(config) {
7398
+ const state = autoShareState(config);
7399
+ return enqueueShareOperation(state, async () => {
7400
+ await loadPendingReindexes(config, state);
7401
+ const warnings = await refreshShareUpdateStateLocked(config, state, { force: false });
7402
+ const syncTeams = /* @__PURE__ */ new Set([...state.behindTeams, ...state.pendingReindexes.keys()]);
7403
+ if (syncTeams.size === 0) {
7404
+ return { syncedTeams: [], warnings };
7405
+ }
7406
+ const syncedTeams = [];
7407
+ const remainingBehind = new Set(state.behindTeams);
7408
+ for (const team of syncTeams) {
7409
+ try {
7410
+ const warning = await runShareSyncQuiet(config, state, { team });
7411
+ if (warning) {
7412
+ warnings.push(warning);
7413
+ } else {
7414
+ remainingBehind.delete(team);
7415
+ syncedTeams.push(team);
7416
+ }
7417
+ } catch (err) {
7418
+ warnings.push(
7419
+ `Auto-sync for shared team "${team}" failed: ${err instanceof Error ? err.message : String(err)}`
7300
7420
  );
7301
- continue;
7302
- }
7303
- if (limit !== void 0 && migratedCount >= limit) {
7304
- break;
7305
7421
  }
7306
- const memoryUri = migratedDurableMemoryUri(config, candidate.hash);
7307
- if (!dryRun && await vikingResourceExists(ov, config, memoryUri)) {
7308
- duplicateCount += 1;
7309
- existingHashes.add(candidate.hash);
7422
+ }
7423
+ state.behindTeams = remainingBehind;
7424
+ state.lastCheckedAt = Date.now();
7425
+ return { syncedTeams, warnings };
7426
+ });
7427
+ }
7428
+ function autoShareState(config) {
7429
+ const key = `${config.agentContextHome}:${config.account}:${config.user}`;
7430
+ let state = autoShareStates.get(key);
7431
+ if (!state) {
7432
+ state = { behindTeams: /* @__PURE__ */ new Set(), lastCheckedAt: 0, pendingReindexes: /* @__PURE__ */ new Map() };
7433
+ autoShareStates.set(key, state);
7434
+ }
7435
+ return state;
7436
+ }
7437
+ function pendingReindexesPath(config) {
7438
+ return (0, import_node_path4.join)(config.agentContextHome, "share", "auto-sync-pending-reindexes.json");
7439
+ }
7440
+ async function loadPendingReindexes(config, state) {
7441
+ const raw = await readFileIfExists(pendingReindexesPath(config));
7442
+ if (!raw) {
7443
+ state.pendingReindexes = /* @__PURE__ */ new Map();
7444
+ return;
7445
+ }
7446
+ const parsed = parseJsonConfigObject(raw);
7447
+ if (!parsed || typeof parsed.teams !== "object" || parsed.teams === null || Array.isArray(parsed.teams)) {
7448
+ state.pendingReindexes = /* @__PURE__ */ new Map();
7449
+ return;
7450
+ }
7451
+ const pending = /* @__PURE__ */ new Map();
7452
+ for (const [team, value] of Object.entries(parsed.teams)) {
7453
+ if (!Array.isArray(value)) {
7454
+ continue;
7455
+ }
7456
+ const changes = [];
7457
+ for (const item of value) {
7458
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
7310
7459
  continue;
7311
7460
  }
7312
- console.log(`${dryRun ? "Would migrate" : "Migrating"} ${legacySourceLabel(candidate)} -> ${memoryUri}`);
7313
- if (!dryRun) {
7314
- await (0, import_promises4.writeFile)(migrationPath, candidate.text, { encoding: "utf8", mode: 384 });
7315
- await (0, import_promises4.chmod)(migrationPath, 384);
7316
- await writeDurableMemoryFile(ov, config, memoryUri, migrationPath, "create");
7317
- existingHashes.add(candidate.hash);
7461
+ const entry = item;
7462
+ if (typeof entry.path === "string" && typeof entry.relativePath === "string" && (entry.status === "added" || entry.status === "removed" || entry.status === "modified")) {
7463
+ changes.push({ path: entry.path, relativePath: entry.relativePath, status: entry.status });
7318
7464
  }
7319
- migratedCount += 1;
7320
7465
  }
7321
- } finally {
7322
- if (!dryRun) {
7323
- await (0, import_promises4.rm)(migrationPath, { force: true });
7466
+ if (changes.length > 0) {
7467
+ pending.set(team, changes);
7324
7468
  }
7325
7469
  }
7326
- console.log(
7327
- [
7328
- `Migration summary: ${migratedCount} ${dryRun ? "would be migrated" : "migrated"}`,
7329
- `${duplicateCount} duplicate(s) skipped`,
7330
- `${sensitiveCount} sensitive-looking item(s) skipped`,
7331
- `${candidates.length} legacy Threadnote item(s) scanned`,
7332
- `source account(s): ${sourceAccounts.join(", ")}`
7333
- ].join("; ")
7334
- );
7470
+ state.pendingReindexes = pending;
7335
7471
  }
7336
- async function runMigrateLifecycle(config, options) {
7337
- const dryRun = options.dryRun === true || options.apply !== true;
7338
- const limit = options.limit ? parsePositiveInteger(options.limit, "lifecycle migration limit") : void 0;
7339
- const ov = await openVikingCliForMode(dryRun);
7340
- const candidates = await legacyLifecycleHandoffCandidates(config);
7341
- const migrationPath = (0, import_node_path4.join)(config.agentContextHome, "lifecycle-memory-migration.txt");
7342
- let existingCount = 0;
7343
- let migratedCount = 0;
7344
- let skippedCount = 0;
7472
+ async function writePendingReindexes(config, state) {
7473
+ const path = pendingReindexesPath(config);
7474
+ if (state.pendingReindexes.size === 0) {
7475
+ await (0, import_promises4.rm)(path, { force: true });
7476
+ return;
7477
+ }
7478
+ const contents = {
7479
+ teams: Object.fromEntries(state.pendingReindexes),
7480
+ version: 1
7481
+ };
7482
+ await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
7483
+ const tempPath = `${path}.${process.pid}.tmp`;
7484
+ await (0, import_promises4.writeFile)(tempPath, `${JSON.stringify(contents, void 0, 2)}
7485
+ `, { encoding: "utf8", mode: 384 });
7486
+ await (0, import_promises4.rename)(tempPath, path);
7487
+ }
7488
+ async function refreshShareUpdateStateLocked(config, state, options) {
7489
+ const now = Date.now();
7490
+ if (!options.force && state.lastCheckedAt > 0 && now - state.lastCheckedAt < AUTO_SHARE_FETCH_INTERVAL_MS) {
7491
+ return [];
7492
+ }
7345
7493
  try {
7346
- for (const candidate of candidates) {
7347
- if (limit !== void 0 && migratedCount >= limit) {
7348
- break;
7494
+ const statuses = await fetchShareUpdateStatuses(config);
7495
+ const nextBehindTeams = new Set(state.behindTeams);
7496
+ for (const status of statuses) {
7497
+ if (status.warning) {
7498
+ continue;
7349
7499
  }
7350
- const destinationUri = lifecycleMigrationUri(config, candidate.metadata, sha256(candidate.original.trim()));
7351
- const migratedMemory = formatMemoryDocument(
7352
- "HANDOFF",
7353
- candidate.metadata,
7354
- ["Migrated legacy handoff from the historical events trail.", "", candidate.original.trim()].join("\n")
7355
- );
7356
- console.log(`${dryRun ? "Would migrate" : "Migrating"} ${candidate.sourceUri} -> ${destinationUri}`);
7357
- if (!dryRun) {
7358
- if (await vikingResourceExists(ov, config, destinationUri)) {
7359
- existingCount += 1;
7360
- console.log(`Archived copy already exists; cleaning up legacy source: ${candidate.sourceUri}`);
7361
- } else {
7362
- await (0, import_promises4.writeFile)(migrationPath, migratedMemory, { encoding: "utf8", mode: 384 });
7363
- await (0, import_promises4.chmod)(migrationPath, 384);
7364
- await ensureMemoryDirectory(ov, config, memoryDirectoryUri(config, candidate.metadata));
7365
- await writeDurableMemoryFile(ov, config, destinationUri, migrationPath, "create");
7366
- }
7367
- const removedOriginal = await removeVikingResourceWithRetry(ov, config, candidate.sourceUri);
7368
- if (!removedOriginal) {
7369
- console.error(
7370
- `Migrated copy stored, but original is still processing. Retry later: threadnote forget ${candidate.sourceUri}`
7371
- );
7372
- skippedCount += 1;
7373
- }
7500
+ if (status.behind > 0) {
7501
+ nextBehindTeams.add(status.team);
7502
+ } else {
7503
+ nextBehindTeams.delete(status.team);
7374
7504
  }
7375
- migratedCount += 1;
7376
7505
  }
7506
+ state.behindTeams = nextBehindTeams;
7507
+ return statuses.flatMap((status) => status.warning ? [status.warning] : []);
7377
7508
  } finally {
7378
- if (!dryRun) {
7379
- await (0, import_promises4.rm)(migrationPath, { force: true });
7380
- }
7509
+ state.lastCheckedAt = Date.now();
7381
7510
  }
7382
- console.log(
7383
- [
7384
- `Lifecycle migration summary: ${migratedCount} clear legacy handoff(s) ${dryRun ? "would be migrated" : "migrated"}`,
7385
- `${existingCount} existing archived copy/copies reused`,
7386
- `${skippedCount} legacy source(s) still processing`,
7387
- `${candidates.length} clear legacy handoff candidate(s) found`,
7388
- dryRun ? "Run with --apply to perform this migration." : void 0
7389
- ].filter((part) => part !== void 0).join("; ")
7390
- );
7391
7511
  }
7392
- async function runRecall(config, options) {
7393
- const ov = await openVikingCliForMode(options.dryRun === true);
7394
- const inferredUri = options.uri ?? (options.inferScope === false ? void 0 : await inferRecallUri(config, options.query));
7395
- const args = ["search", options.query];
7396
- if (inferredUri) {
7397
- args.push("--uri", inferredUri);
7398
- console.log(`Recall scope: ${inferredUri}`);
7399
- }
7400
- if (options.nodeLimit) {
7401
- args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
7402
- }
7403
- await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
7404
- await augmentRecallWithSeededResources(config, ov, options, inferredUri);
7405
- await printExactMemoryMatches(config, ov, options.query, {
7406
- dryRun: options.dryRun === true,
7407
- includeArchived: options.includeArchived === true
7408
- });
7512
+ function enqueueShareOperation(state, action) {
7513
+ const previous = state.operationPromise ?? Promise.resolve();
7514
+ const current = previous.catch(() => void 0).then(action);
7515
+ state.operationPromise = current.catch(() => void 0);
7516
+ return current;
7409
7517
  }
7410
- async function augmentRecallWithSeededResources(config, ov, options, inferredUri) {
7411
- if (options.uri || options.inferScope === false) {
7412
- return;
7413
- }
7414
- const project = await inferProjectFromQuery(config.manifestPath, options.query);
7415
- if (!project) {
7416
- return;
7417
- }
7418
- const projectResourceUri = trimTrailingSlash(project.uri);
7419
- if (!projectResourceUri.startsWith("viking://") || projectResourceUri === inferredUri) {
7420
- return;
7518
+ async function fetchShareUpdateStatuses(config) {
7519
+ const teamsFile = await readTeamsFile(config);
7520
+ const teams = Object.entries(teamsFile.teams);
7521
+ if (teams.length === 0) {
7522
+ return [];
7421
7523
  }
7422
- const args = ["search", options.query, "--uri", projectResourceUri];
7423
- if (options.nodeLimit) {
7424
- args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
7524
+ const git = await requiredExecutable("git");
7525
+ const statuses = [];
7526
+ for (const [name, team] of teams) {
7527
+ const fetchResult = await runCommand(git, ["-C", team.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
7528
+ allowFailure: true
7529
+ });
7530
+ if (fetchResult.exitCode !== 0) {
7531
+ statuses.push({
7532
+ behind: 0,
7533
+ team: name,
7534
+ warning: `Auto-sync check for shared team "${name}" failed: ${fetchResult.stderr.trim() || fetchResult.stdout.trim() || "unknown git fetch error"}`
7535
+ });
7536
+ continue;
7537
+ }
7538
+ const behind = await gitOutput(team.worktree, ["rev-list", "--count", "HEAD..@{u}"], false);
7539
+ if (behind === void 0) {
7540
+ statuses.push({
7541
+ behind: 0,
7542
+ team: name,
7543
+ warning: `Auto-sync check for shared team "${name}" failed: could not read upstream behind count.`
7544
+ });
7545
+ continue;
7546
+ }
7547
+ statuses.push({ behind: Number.parseInt(behind, 10) || 0, team: name });
7425
7548
  }
7426
- console.log(`
7427
- Also searching seeded resources: ${projectResourceUri}`);
7428
- await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
7549
+ return statuses;
7429
7550
  }
7430
- async function runRead(config, uri, options) {
7431
- assertVikingUri(uri);
7432
- const ov = await openVikingCliForMode(options.dryRun === true);
7433
- const result = await maybeRun(options.dryRun === true, ov, withIdentity(config, ["read", uri]));
7434
- if (result && result.stdout.includes("[Directory overview is not ready]") && (uri.endsWith("/.overview.md") || uri.endsWith("/.abstract.md"))) {
7435
- const parentUri2 = parentVikingUri(uri);
7436
- console.log("\nThis is a generated summary placeholder. To read the underlying content, inspect leaf nodes:");
7437
- console.log(` threadnote list ${parentUri2} --all --recursive`);
7551
+ async function runShareSync(config, options) {
7552
+ const team = await resolveTeam(config, options.team);
7553
+ const dryRun = options.dryRun === true;
7554
+ const git = await requiredExecutable("git");
7555
+ const worktree = team.config.worktree;
7556
+ if (!dryRun) {
7557
+ await ensureSharedGitignore(worktree, git, false);
7438
7558
  }
7439
- }
7440
- async function runList(config, uri, options) {
7441
- assertVikingUri(uri);
7442
- const ov = await openVikingCliForMode(options.dryRun === true);
7443
- const args = ["ls", uri];
7444
- if (options.all === true) {
7445
- args.push("--all");
7559
+ if (await hasUncommittedChanges(worktree)) {
7560
+ if (options.autoCommit === false) {
7561
+ throw new Error(
7562
+ `Worktree ${worktree} has uncommitted changes. Commit them yourself or rerun without --no-auto-commit.`
7563
+ );
7564
+ }
7565
+ const message = options.message ?? `share: sync ${(/* @__PURE__ */ new Date()).toISOString()}`;
7566
+ await stageShareableChanges(dryRun, git, worktree);
7567
+ await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
7446
7568
  }
7447
- if (options.recursive === true) {
7448
- args.push("--recursive");
7569
+ const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], dryRun);
7570
+ await maybeRun(dryRun, git, ["-C", worktree, "fetch", DEFAULT_GIT_REMOTE_NAME]);
7571
+ const pullResult = dryRun ? void 0 : await runCommand(git, ["-C", worktree, "pull", "--rebase", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
7572
+ if (dryRun) {
7573
+ console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "pull", "--rebase", DEFAULT_GIT_REMOTE_NAME])}`);
7574
+ } else if (pullResult && pullResult.exitCode !== 0) {
7575
+ if (await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-merge")) || await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-apply"))) {
7576
+ throw new Error(
7577
+ `git pull --rebase reported conflicts in ${worktree}. The worktree is in a rebase-in-progress state.
7578
+ Resolve the conflicts in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then re-run \`threadnote share sync\`.`
7579
+ );
7580
+ }
7581
+ throw new Error(
7582
+ `git pull --rebase failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
7583
+ );
7449
7584
  }
7450
- if (options.simple === true) {
7451
- args.push("--simple");
7585
+ const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], dryRun);
7586
+ if (!dryRun && beforeRev && afterRev && beforeRev !== afterRev) {
7587
+ const changes = await listChangedFiles(worktree, beforeRev, afterRev);
7588
+ await applyChangesToOpenViking(config, team.config, changes);
7589
+ console.log(`Reindexed ${changes.length} file change(s) into OpenViking.`);
7590
+ } else if (!dryRun) {
7591
+ console.log("No upstream changes to reindex.");
7452
7592
  }
7453
- if (options.nodeLimit) {
7454
- args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
7593
+ if (options.push !== false) {
7594
+ await maybeRun(dryRun, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
7455
7595
  }
7456
- await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
7457
- }
7458
- async function runHandoff(config, options) {
7459
- const { bodyText, metadata } = await buildHandoff(options);
7460
- await storeMemory(config, {
7461
- bodyText,
7462
- dryRun: options.dryRun === true,
7463
- metadata,
7464
- replaceUri: options.replace,
7465
- title: "HANDOFF"
7466
- });
7467
7596
  }
7468
- async function runArchive(config, uri, options) {
7469
- assertVikingUri(uri);
7470
- const ov = await openVikingCliForMode(options.dryRun === true);
7471
- const readResult = await maybeRun(options.dryRun === true, ov, withIdentity(config, ["read", uri]));
7472
- const original = readResult?.stdout.trim();
7473
- if (options.dryRun === true) {
7474
- const fallbackMetadata = {
7475
- archivedFrom: uri,
7476
- kind: options.kind ?? "handoff",
7477
- project: normalizeOptionalMetadata(options.project),
7478
- sourceAgentClient: "threadnote",
7479
- status: "archived",
7480
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7481
- topic: normalizeOptionalMetadata(options.topic)
7482
- };
7483
- await storeMemory(config, {
7484
- bodyText: ["Archived original Threadnote memory.", "", "<original memory content would be read here>"].join("\n"),
7485
- dryRun: true,
7486
- metadata: fallbackMetadata,
7487
- title: "MEMORY"
7488
- });
7489
- console.log(formatShellCommand(ov, withIdentity(config, ["rm", uri])));
7490
- return;
7597
+ async function runShareSyncQuiet(config, state, options) {
7598
+ const team = await resolveTeam(config, options.team);
7599
+ const git = await requiredExecutable("git");
7600
+ const worktree = team.config.worktree;
7601
+ const pendingChanges = state.pendingReindexes.get(team.name);
7602
+ if (pendingChanges) {
7603
+ await applyChangesToOpenViking(config, team.config, pendingChanges, { quiet: true });
7604
+ state.pendingReindexes.delete(team.name);
7605
+ await writePendingReindexes(config, state);
7491
7606
  }
7492
- if (!original) {
7493
- throw new Error(`Could not read ${uri} before archiving.`);
7607
+ if (await hasUncommittedChanges(worktree)) {
7608
+ return `Shared team "${team.name}" has uncommitted changes; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or resolve them.`;
7494
7609
  }
7495
- const inferredMetadata = inferMemoryMetadata(original);
7496
- const metadata = {
7497
- archivedFrom: uri,
7498
- kind: options.kind ?? inferredMetadata.kind ?? "handoff",
7499
- project: normalizeOptionalMetadata(options.project) ?? inferredMetadata.project,
7500
- sourceAgentClient: "threadnote",
7501
- status: "archived",
7502
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7503
- topic: normalizeOptionalMetadata(options.topic) ?? inferredMetadata.topic
7504
- };
7505
- await storeMemory(config, {
7506
- bodyText: ["Archived original Threadnote memory.", "", original].join("\n"),
7507
- dryRun: false,
7508
- metadata,
7509
- title: "MEMORY"
7510
- });
7511
- const removedOriginal = await removeVikingResourceWithRetry(ov, config, uri);
7512
- if (removedOriginal) {
7513
- console.log(`Archived original memory: ${uri}`);
7514
- } else {
7515
- console.error(`Archive stored, but original memory is still processing. Retry later: threadnote forget ${uri}`);
7610
+ const ahead = await gitOutput(worktree, ["rev-list", "--count", "@{u}..HEAD"], false);
7611
+ if (ahead === void 0) {
7612
+ return `Shared team "${team.name}" upstream status is unknown; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to inspect and resolve it.`;
7516
7613
  }
7517
- }
7518
- async function runForget(config, uri, options) {
7519
- assertVikingUri(uri);
7520
- const ov = await openVikingCliForMode(options.dryRun === true);
7521
- if (options.dryRun === true) {
7522
- await maybeRun(true, ov, withIdentity(config, ["rm", uri]));
7523
- return;
7614
+ if ((Number.parseInt(ahead, 10) || 0) > 0) {
7615
+ return `Shared team "${team.name}" has local commits ahead of upstream; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or reconcile them.`;
7524
7616
  }
7525
- const removed = await removeVikingResourceWithRetry(ov, config, uri);
7526
- if (!removed) {
7527
- throw new Error(`Resource is still being processed; retry later: threadnote forget ${uri}`);
7617
+ const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
7618
+ const pullResult = await runCommand(git, ["-C", worktree, "rebase", "@{u}"], { allowFailure: true });
7619
+ if (pullResult.exitCode !== 0) {
7620
+ if (await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-merge")) || await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-apply"))) {
7621
+ throw new Error(
7622
+ `Automatic share sync hit git conflicts in ${worktree}. Resolve them in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then rerun recall/read.`
7623
+ );
7624
+ }
7625
+ throw new Error(
7626
+ `Automatic share sync failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
7627
+ );
7528
7628
  }
7629
+ const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
7630
+ if (beforeRev && afterRev && beforeRev !== afterRev) {
7631
+ const changes = await listChangedFiles(worktree, beforeRev, afterRev);
7632
+ if (changes.length > 0) {
7633
+ state.pendingReindexes.set(team.name, changes);
7634
+ await writePendingReindexes(config, state);
7635
+ await applyChangesToOpenViking(config, team.config, changes, { quiet: true });
7636
+ state.pendingReindexes.delete(team.name);
7637
+ await writePendingReindexes(config, state);
7638
+ }
7639
+ }
7640
+ return void 0;
7529
7641
  }
7530
- async function runExportPack(config, options) {
7531
- const ov = await openVikingCliForMode(options.dryRun === true);
7532
- const defaultPath = (0, import_node_path4.join)(config.agentContextHome, `threadnote-${safeTimestamp()}.ovpack`);
7533
- const outputPath = expandPath(options.path ?? defaultPath);
7534
- await maybeRun(options.dryRun === true, ov, withIdentity(config, ["export", options.uri ?? "viking://", outputPath]));
7642
+ async function stageShareableChanges(dryRun, git, worktree) {
7643
+ const pathspecs = [":(top)README.md", ":(top).gitignore", ...SHAREABLE_MEMORY_KIND_DIRS.map((dir) => `:(top)${dir}`)];
7644
+ await maybeRun(dryRun, git, ["-C", worktree, "add", "--", ...pathspecs], { allowFailure: true });
7535
7645
  }
7536
- async function runImportPack(config, options) {
7537
- if (!options.path) {
7538
- throw new Error("Provide --path for import-pack.");
7646
+ async function runSharePublish(config, sourceUri, options) {
7647
+ assertVikingUri(sourceUri);
7648
+ const team = await resolveTeam(config, options.team);
7649
+ const dryRun = options.dryRun === true;
7650
+ const preview = options.preview === true;
7651
+ if (isInSharedNamespace(config, sourceUri)) {
7652
+ throw new Error(`Memory ${sourceUri} is already in the shared namespace.`);
7539
7653
  }
7540
- const ov = await openVikingCliForMode(options.dryRun === true);
7541
- await maybeRun(
7542
- options.dryRun === true,
7543
- ov,
7544
- withIdentity(config, ["import", expandPath(options.path), options.targetUri ?? "viking://"])
7545
- );
7546
- }
7547
- async function inferRecallUri(config, query) {
7548
- if (!/\bskills?\b/.test(query.toLowerCase())) {
7549
- return void 0;
7550
- }
7551
- const project = await inferProjectFromQuery(config.manifestPath, query);
7552
- return project ? `viking://resources/agent-skills/repo-local-${uriSegment(project.name)}` : "viking://resources/agent-skills";
7553
- }
7554
- async function printExactMemoryMatches(config, ov, query, options) {
7555
- const terms = exactRecallTerms(query);
7556
- if (terms.length === 0) {
7557
- return;
7558
- }
7559
- const scopes = exactMemoryScopes(config, options.includeArchived);
7560
- const outputs = [];
7561
- for (const term of terms) {
7562
- for (const scope of scopes) {
7563
- const args = withIdentity(config, ["grep", term, "--uri", scope, "--node-limit", "5"]);
7564
- if (options.dryRun) {
7565
- outputs.push(formatShellCommand(ov, args));
7566
- continue;
7567
- }
7568
- const result = await runCommand(ov, args, { allowFailure: true });
7569
- const output2 = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
7570
- if (result.exitCode === 0 && grepOutputHasMatches(output2)) {
7571
- outputs.push(output2);
7572
- }
7654
+ const ov = await openVikingCliForMode(dryRun);
7655
+ const rawContent = await readMemoryContent(config, ov, sourceUri, dryRun);
7656
+ const stripped = stripPersonalProvenance(rawContent);
7657
+ const scrub = applyScrubber(stripped, { redact: options.redact === true });
7658
+ const targetUri = sharedUriFor(config, sourceUri, team.name);
7659
+ if (preview) {
7660
+ console.log(`PREVIEW source: ${sourceUri}`);
7661
+ console.log(`PREVIEW destination: ${targetUri}`);
7662
+ if (scrub.blocker) {
7663
+ console.log(
7664
+ `PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or rerun with --redact for soft-leak patterns.`
7665
+ );
7666
+ return;
7573
7667
  }
7574
- }
7575
- if (outputs.length === 0) {
7668
+ for (const redaction of scrub.redactions) {
7669
+ console.log(`PREVIEW redact: ${redaction.count}\xD7 ${redaction.name}`);
7670
+ }
7671
+ console.log("-----BEGIN PREVIEW-----");
7672
+ console.log(scrub.cleaned);
7673
+ console.log("-----END PREVIEW-----");
7576
7674
  return;
7577
7675
  }
7578
- console.log("\nExact durable memory matches:");
7579
- console.log(outputs.join("\n\n"));
7580
- }
7581
- async function storeMemory(config, options) {
7582
- if (options.replaceUri) {
7583
- assertVikingUri(options.replaceUri);
7676
+ if (scrub.blocker) {
7677
+ throw new Error(
7678
+ `Refusing to publish ${sourceUri}: possible ${scrub.blocker}. Strip the sensitive value or pass --redact for soft-leak patterns.`
7679
+ );
7584
7680
  }
7585
- const ov = await openVikingCliForMode(options.dryRun);
7586
- const memoryPath = (0, import_node_path4.join)(config.agentContextHome, "last-memory.txt");
7587
- const candidateMetadata = { ...options.metadata, supersedes: options.replaceUri };
7588
- const candidateMemory = formatMemoryDocument(options.title, candidateMetadata, options.bodyText);
7589
- const memoryUri = memoryUriFor(config, candidateMemory, candidateMetadata);
7590
- const isInPlaceUpdate = options.replaceUri !== void 0 && options.replaceUri === memoryUri;
7591
- const finalMetadata = isInPlaceUpdate ? { ...options.metadata, supersedes: void 0 } : candidateMetadata;
7592
- const memory = isInPlaceUpdate ? formatMemoryDocument(options.title, finalMetadata, options.bodyText) : candidateMemory;
7593
- const writeMode = await memoryWriteMode(ov, config, memoryUri, finalMetadata);
7594
- if (options.dryRun) {
7595
- console.log(memory);
7596
- console.log("\nWould run:");
7597
- console.log(
7598
- formatShellCommand(
7599
- ov,
7600
- withIdentity(config, [
7601
- "write",
7602
- memoryUri,
7603
- "--from-file",
7604
- memoryPath,
7605
- "--mode",
7606
- writeMode,
7607
- "--wait",
7608
- "--timeout",
7609
- "120"
7610
- ])
7611
- )
7681
+ for (const redaction of scrub.redactions) {
7682
+ console.log(`Redacted ${redaction.count}\xD7 ${redaction.name} before publish.`);
7683
+ }
7684
+ const content = scrub.cleaned;
7685
+ if (!dryRun && await vikingResourceExists(ov, config, targetUri)) {
7686
+ throw new Error(
7687
+ `Refusing to publish: ${targetUri} already exists in the shared namespace. Inspect it via threadnote read; if it should be replaced, forget the existing shared copy first.`
7612
7688
  );
7613
- if (options.replaceUri && !isInPlaceUpdate) {
7614
- console.log(formatShellCommand(ov, withIdentity(config, ["rm", options.replaceUri])));
7615
- }
7616
- return;
7617
7689
  }
7618
- await (0, import_promises4.writeFile)(memoryPath, memory, { encoding: "utf8", mode: 384 });
7619
- await (0, import_promises4.chmod)(memoryPath, 384);
7620
- await ensureMemoryDirectory(ov, config, memoryDirectoryUri(config, finalMetadata));
7621
- await writeDurableMemoryFile(ov, config, memoryUri, memoryPath, writeMode);
7622
- console.log(`Stored memory: ${memoryUri}`);
7623
- if (options.replaceUri && !isInPlaceUpdate) {
7624
- const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config, options.replaceUri);
7625
- if (removedReplacedMemory) {
7626
- console.log(`Forgot replaced memory: ${options.replaceUri}`);
7627
- } else {
7628
- console.error(
7629
- `Replacement stored, but the superseded memory is still processing. Retry later: threadnote forget ${options.replaceUri}`
7690
+ await ensureSharedDirectoryChain(config, ov, targetUri, dryRun);
7691
+ await writeMemoryFile(config, ov, targetUri, content, "create", dryRun);
7692
+ await removeWithRollback(config, ov, sourceUri, targetUri, team.config.worktree, dryRun, "publish");
7693
+ const git = await requiredExecutable("git");
7694
+ const worktree = team.config.worktree;
7695
+ const relativePath = vikingUriToWorktreeRelative(config, targetUri, team.name);
7696
+ const message = options.message ?? `share: publish ${relativePath}`;
7697
+ await maybeRun(dryRun, git, ["-C", worktree, "add", "--", relativePath]);
7698
+ await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
7699
+ if (options.push !== false) {
7700
+ const pushResult = dryRun ? void 0 : await runCommand(git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
7701
+ if (dryRun) {
7702
+ console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME])}`);
7703
+ } else if (pushResult && pushResult.exitCode !== 0) {
7704
+ const detail = pushResult.stderr.trim() || pushResult.stdout.trim() || "unknown error";
7705
+ throw new Error(
7706
+ `Memory was committed locally but git push failed: ${detail}
7707
+ Resolve the remote issue (auth, network, branch protection), then run: threadnote share sync`
7630
7708
  );
7631
7709
  }
7632
- } else if (isInPlaceUpdate) {
7633
- console.log(`Updated existing memory in place: ${memoryUri}`);
7634
7710
  }
7711
+ console.log(`Published ${sourceUri} -> ${targetUri}`);
7635
7712
  }
7636
- async function writeDurableMemoryFile(ov, config, memoryUri, memoryPath, writeMode) {
7637
- const args = withIdentity(config, [
7638
- "write",
7639
- memoryUri,
7640
- "--from-file",
7641
- memoryPath,
7642
- "--mode",
7643
- writeMode,
7644
- "--wait",
7645
- "--timeout",
7646
- "120"
7647
- ]);
7648
- for (let attempt = 0; attempt < 4; attempt += 1) {
7649
- console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
7650
- const result = await runCommand(ov, args, { allowFailure: true });
7651
- if (result.exitCode === 0) {
7652
- if (result.stdout.trim()) {
7653
- console.log(result.stdout.trim());
7654
- }
7655
- if (result.stderr.trim()) {
7656
- console.error(result.stderr.trim());
7657
- }
7658
- return;
7659
- }
7660
- if (await vikingResourceExists(ov, config, memoryUri)) {
7661
- console.log("OpenViking accepted the memory but returned before the wait completed; waiting for indexing.");
7662
- await waitForOpenVikingQueue(ov, config);
7663
- return;
7664
- }
7665
- if (!isResourceBusy(result.stderr, result.stdout) || attempt === 3) {
7666
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
7667
- }
7668
- await sleep(1e3 * (attempt + 1));
7713
+ async function runShareUnpublish(config, sourceUri, options) {
7714
+ assertVikingUri(sourceUri);
7715
+ const team = await resolveTeam(config, options.team);
7716
+ const dryRun = options.dryRun === true;
7717
+ if (!isInTeamNamespace(config, sourceUri, team.name)) {
7718
+ throw new Error(`Memory ${sourceUri} is not in team "${team.name}" shared namespace.`);
7669
7719
  }
7670
- }
7671
- async function waitForOpenVikingQueue(ov, config) {
7672
- const result = await runCommand(ov, withIdentity(config, ["wait", "--timeout", "120"]), { allowFailure: true });
7673
- if (result.stdout.trim()) {
7674
- console.log(result.stdout.trim());
7720
+ const ov = await openVikingCliForMode(dryRun);
7721
+ const content = await readMemoryContent(config, ov, sourceUri, dryRun);
7722
+ const targetUri = personalUriFor(config, sourceUri, team.name);
7723
+ if (!dryRun && await vikingResourceExists(ov, config, targetUri)) {
7724
+ throw new Error(
7725
+ `Refusing to unpublish: a personal memory already exists at ${targetUri}. Move or forget it first, then retry.`
7726
+ );
7675
7727
  }
7676
- if (result.stderr.trim()) {
7677
- console.error(result.stderr.trim());
7728
+ await writeMemoryFile(config, ov, targetUri, content, "create", dryRun);
7729
+ await removeWithRollback(config, ov, sourceUri, targetUri, team.config.worktree, dryRun, "unpublish");
7730
+ const git = await requiredExecutable("git");
7731
+ const worktree = team.config.worktree;
7732
+ const relativePath = vikingUriToWorktreeRelative(config, sourceUri, team.name);
7733
+ const message = options.message ?? `share: unpublish ${relativePath}`;
7734
+ await maybeRun(dryRun, git, ["-C", worktree, "rm", relativePath], { allowFailure: true });
7735
+ await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
7736
+ if (options.push !== false) {
7737
+ await maybeRun(dryRun, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
7678
7738
  }
7739
+ console.log(`Unpublished ${sourceUri} -> ${targetUri}`);
7679
7740
  }
7680
- async function removeVikingResourceWithRetry(ov, config, uri) {
7681
- const args = withIdentity(config, ["rm", uri]);
7682
- for (let attempt = 0; attempt < 4; attempt += 1) {
7683
- console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
7684
- const result = await runCommand(ov, args, { allowFailure: true });
7685
- if (result.exitCode === 0) {
7686
- if (result.stdout.trim()) {
7687
- console.log(result.stdout.trim());
7688
- }
7689
- if (result.stderr.trim()) {
7690
- console.error(result.stderr.trim());
7691
- }
7692
- return true;
7693
- }
7694
- if (isResourceBusy(result.stderr, result.stdout) && attempt === 3) {
7695
- return false;
7696
- }
7697
- if (!isResourceBusy(result.stderr, result.stdout)) {
7698
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
7699
- }
7700
- await sleep(1e3 * (attempt + 1));
7741
+ async function runShareList(config, _options) {
7742
+ const teams = await readTeamsFile(config);
7743
+ const entries = Object.values(teams.teams);
7744
+ if (entries.length === 0) {
7745
+ console.log("No shared teams configured. Run: threadnote share init <remote-url>");
7746
+ return;
7747
+ }
7748
+ for (const team of entries) {
7749
+ const marker = team.name === teams.defaultTeam ? " (default)" : "";
7750
+ console.log(`- ${team.name}${marker}`);
7751
+ console.log(` remote: ${team.remote}`);
7752
+ console.log(` worktree: ${portablePath(team.worktree)}`);
7753
+ console.log(` gitdir: ${portablePath(team.gitdir)}`);
7754
+ console.log(` added: ${team.addedAt}`);
7701
7755
  }
7702
- return false;
7703
- }
7704
- async function vikingResourceExists(ov, config, uri) {
7705
- const stat4 = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
7706
- return stat4.exitCode === 0;
7707
- }
7708
- async function ensureDurableMemoryDirectory(ov, config) {
7709
- await ensureMemoryDirectory(ov, config, durableMemoryDirectoryUri(config));
7710
7756
  }
7711
- async function ensureMemoryDirectory(ov, config, directoryUri) {
7712
- for (const uri of vikingDirectoryChain(directoryUri)) {
7713
- const statResult = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
7714
- if (statResult.exitCode === 0) {
7715
- continue;
7757
+ async function runShareRemove(config, options) {
7758
+ const team = await resolveTeam(config, options.team);
7759
+ const dryRun = options.dryRun === true;
7760
+ const teamsFile = await readTeamsFile(config);
7761
+ const remaining = {};
7762
+ for (const [name, value] of Object.entries(teamsFile.teams)) {
7763
+ if (name !== team.name) {
7764
+ remaining[name] = value;
7716
7765
  }
7717
- await maybeRun(
7718
- false,
7719
- ov,
7720
- withIdentity(config, ["mkdir", uri, "--description", "Threadnote lifecycle-aware local memories."])
7721
- );
7766
+ }
7767
+ const remainingNames = Object.keys(remaining);
7768
+ const nextDefault = teamsFile.defaultTeam === team.name ? remainingNames[0] : teamsFile.defaultTeam;
7769
+ const updated = { defaultTeam: nextDefault, teams: remaining, version: TEAMS_FILE_VERSION };
7770
+ if (dryRun) {
7771
+ console.log(`Would update teams file: ${teamsFilePath(config)}`);
7772
+ } else {
7773
+ await writeTeamsFile(config, updated);
7774
+ console.log(`Removed team "${team.name}" from teams.json.`);
7775
+ }
7776
+ if (options.keepFiles !== true) {
7777
+ await removePath(team.config.worktree, "shared worktree", dryRun);
7778
+ await removePath(team.config.gitdir, "shared gitdir", dryRun);
7779
+ } else {
7780
+ console.log(`Keeping files at ${portablePath(team.config.worktree)} and ${portablePath(team.config.gitdir)}`);
7722
7781
  }
7723
7782
  }
7724
- function durableMemoryDirectoryUri(config) {
7725
- return `viking://user/${uriSegment(config.user)}/memories/events`;
7726
- }
7727
- function migratedDurableMemoryUri(config, hash) {
7728
- return `${durableMemoryDirectoryUri(config)}/threadnote-migrated-${hash.slice(0, 16)}.md`;
7729
- }
7730
- async function hasLegacyLifecycleHandoffCandidates(config) {
7731
- return (await legacyLifecycleHandoffCandidates(config, 1)).length > 0;
7732
- }
7733
- async function legacyLifecycleHandoffCandidates(config, limit) {
7734
- const eventsRoot = (0, import_node_path4.join)(localUserMemoriesRoot(config), "events");
7735
- let entries;
7736
- try {
7737
- entries = await (0, import_promises4.readdir)(eventsRoot, { withFileTypes: true });
7738
- } catch (_err) {
7739
- return [];
7783
+ function normalizeTeamName(input2) {
7784
+ const candidate = (input2 ?? "default").trim();
7785
+ if (!candidate) {
7786
+ return "default";
7740
7787
  }
7741
- const candidates = [];
7742
- for (const entry of entries) {
7743
- if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md")) {
7744
- continue;
7745
- }
7746
- const sourcePath = (0, import_node_path4.join)(eventsRoot, entry.name);
7747
- const original = await readTextIfExists(sourcePath);
7748
- if (!original || !isClearLegacyHandoffMemory(original) || sensitiveMemoryReason(original)) {
7749
- continue;
7750
- }
7751
- const sourceUri = `${durableMemoryDirectoryUri(config)}/${entry.name}`;
7752
- candidates.push({
7753
- metadata: {
7754
- archivedFrom: sourceUri,
7755
- kind: "handoff",
7756
- project: inferLegacyProject(original),
7757
- sourceAgentClient: "threadnote",
7758
- status: "archived",
7759
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
7760
- },
7761
- original,
7762
- sourceUri
7763
- });
7764
- if (limit !== void 0 && candidates.length >= limit) {
7765
- break;
7766
- }
7788
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(candidate) || /^\.+$/.test(candidate)) {
7789
+ throw new Error(
7790
+ `Invalid team name "${input2}". Team names must start with a lowercase letter or digit and contain only [a-z0-9._-]. Single-dot or dot-only names are rejected so they don't collapse to the shared-root or parent directory.`
7791
+ );
7767
7792
  }
7768
- return candidates;
7793
+ return candidate;
7769
7794
  }
7770
- function lifecycleMigrationUri(config, metadata, hash) {
7771
- return `${memoryDirectoryUri(config, metadata)}/legacy-${hash.slice(0, 16)}.md`;
7795
+ function teamsFilePath(config) {
7796
+ return (0, import_node_path4.join)(config.agentContextHome, "share", "teams.json");
7772
7797
  }
7773
- function exactMemoryScopes(config, includeArchived) {
7774
- const userBase = `viking://user/${uriSegment(config.user)}/memories`;
7775
- const scopes = [
7776
- `${userBase}/preferences`,
7777
- `${userBase}/durable/projects`,
7778
- `${userBase}/handoffs/active`,
7779
- `${userBase}/incidents/active`,
7780
- `${userBase}/events`,
7781
- `${userBase}/shared`,
7782
- `viking://agent/${uriSegment(config.agentId)}/memories`,
7783
- // Seeded project resources (READMEs, AGENTS.md, SKILL.md, docs/**) live
7784
- // under viking://resources/repos/<project>. Include them so an exact-term
7785
- // grep in a recall surfaces matches in seeded guidance, not only memories.
7786
- "viking://resources/repos"
7787
- ];
7788
- return includeArchived ? [...scopes, `${userBase}/durable/archived`, `${userBase}/handoffs/archived`, `${userBase}/incidents/archived`] : scopes;
7798
+ function teamWorktreePath(config, team) {
7799
+ return (0, import_node_path4.join)(
7800
+ config.agentContextHome,
7801
+ "data",
7802
+ "viking",
7803
+ config.account,
7804
+ "user",
7805
+ uriSegment(config.user),
7806
+ "memories",
7807
+ SHARED_SEGMENT,
7808
+ team
7809
+ );
7789
7810
  }
7790
- function memoryUriFor(config, memory, metadata) {
7791
- const filename = shouldUseStableMemoryUri(metadata) ? `${uriSegment(metadata.topic ?? "current")}.md` : `threadnote-${safeTimestamp()}-${sha256(memory).slice(0, 12)}.md`;
7792
- return `${memoryDirectoryUri(config, metadata)}/${filename}`;
7811
+ function teamGitdirPath(config, team) {
7812
+ return (0, import_node_path4.join)(config.agentContextHome, "share", "teams", `${team}.gitdir`);
7793
7813
  }
7794
- function memoryDirectoryUri(config, metadata) {
7795
- const baseUri = `viking://user/${uriSegment(config.user)}/memories`;
7796
- const projectSegment = uriSegment(metadata.project ?? "general");
7797
- switch (metadata.kind) {
7798
- case "preference":
7799
- return metadata.status === "active" ? `${baseUri}/preferences` : `${baseUri}/preferences/${uriSegment(metadata.status)}`;
7800
- case "handoff":
7801
- return `${baseUri}/handoffs/${uriSegment(metadata.status)}/${projectSegment}`;
7802
- case "incident":
7803
- return `${baseUri}/incidents/${uriSegment(metadata.status)}/${projectSegment}`;
7804
- case "smoke":
7805
- return `${baseUri}/smoke/${uriSegment(metadata.status)}`;
7806
- case "durable":
7807
- return metadata.status === "active" ? `${baseUri}/durable/projects/${projectSegment}` : `${baseUri}/durable/${uriSegment(metadata.status)}/${projectSegment}`;
7814
+ async function readTeamsFile(config) {
7815
+ const path = teamsFilePath(config);
7816
+ const raw = await readFileIfExists(path);
7817
+ if (!raw) {
7818
+ return { teams: {}, version: TEAMS_FILE_VERSION };
7808
7819
  }
7809
- }
7810
- function shouldUseStableMemoryUri(metadata) {
7811
- return metadata.status === "active" && metadata.topic !== void 0 && metadata.kind !== "smoke";
7812
- }
7813
- async function memoryWriteMode(ov, config, memoryUri, metadata) {
7814
- if (!shouldUseStableMemoryUri(metadata)) {
7815
- return "create";
7820
+ const parsed = parseJsonConfigObject(raw);
7821
+ if (!parsed) {
7822
+ throw new Error(`Could not parse teams file ${path}`);
7816
7823
  }
7817
- return await vikingResourceExists(ov, config, memoryUri) ? "replace" : "create";
7818
- }
7819
- function vikingDirectoryChain(directoryUri) {
7820
- const prefix = "viking://";
7821
- if (!directoryUri.startsWith(prefix)) {
7822
- return [directoryUri];
7824
+ if (typeof parsed.version === "number" && parsed.version > TEAMS_FILE_VERSION) {
7825
+ throw new Error(
7826
+ `Teams file ${path} was written with version ${parsed.version}; this Threadnote binary understands up to version ${TEAMS_FILE_VERSION}. Upgrade Threadnote (\`threadnote update\`) before continuing.`
7827
+ );
7823
7828
  }
7824
- const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
7825
- const startIndex = parts[0] === "user" && parts.length > 2 ? 3 : 1;
7826
- const chain = [];
7827
- for (let index = startIndex; index <= parts.length; index += 1) {
7828
- chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
7829
+ const teams = {};
7830
+ if (typeof parsed.teams === "object" && parsed.teams !== null && !Array.isArray(parsed.teams)) {
7831
+ for (const [name, value] of Object.entries(parsed.teams)) {
7832
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
7833
+ console.warn(`Skipping non-object team entry "${name}" in ${path}.`);
7834
+ continue;
7835
+ }
7836
+ const entry = value;
7837
+ if (typeof entry.remote !== "string" || entry.remote.length === 0) {
7838
+ console.warn(`Skipping team entry "${name}" in ${path}: missing or empty "remote" field.`);
7839
+ continue;
7840
+ }
7841
+ teams[name] = {
7842
+ addedAt: typeof entry.addedAt === "string" ? entry.addedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
7843
+ gitdir: typeof entry.gitdir === "string" ? entry.gitdir : teamGitdirPath(config, name),
7844
+ name,
7845
+ remote: entry.remote,
7846
+ worktree: typeof entry.worktree === "string" ? entry.worktree : teamWorktreePath(config, name)
7847
+ };
7848
+ }
7829
7849
  }
7830
- return chain;
7831
- }
7832
- function formatMemoryDocument(title, metadata, body) {
7833
- const header = [
7834
- title,
7835
- `kind: ${metadata.kind}`,
7836
- `status: ${metadata.status}`,
7837
- metadata.project ? `project: ${metadata.project}` : void 0,
7838
- metadata.topic ? `topic: ${metadata.topic}` : void 0,
7839
- `source_agent_client: ${metadata.sourceAgentClient}`,
7840
- `timestamp: ${metadata.timestamp}`,
7841
- metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
7842
- metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
7843
- ].filter((line) => line !== void 0);
7844
- return [...header, "", body.trim()].join("\n");
7850
+ const defaultTeam = typeof parsed.defaultTeam === "string" ? parsed.defaultTeam : void 0;
7851
+ return { defaultTeam, teams, version: TEAMS_FILE_VERSION };
7845
7852
  }
7846
- function inferMemoryMetadata(memory) {
7847
- const header = memory.slice(0, Math.max(0, memory.indexOf("\n\n")) || memory.length);
7848
- const firstLine2 = header.split("\n")[0]?.trim();
7849
- const kind = parseOptionalMemoryKind(parseHeaderValue(header, "kind")) ?? (firstLine2 === "HANDOFF" ? "handoff" : void 0);
7850
- const status = parseOptionalMemoryStatus(parseHeaderValue(header, "status"));
7851
- const project = normalizeOptionalMetadata(parseHeaderValue(header, "project")) ?? normalizeOptionalMetadata(parseHeaderValue(header, "repo"));
7852
- const topic = normalizeOptionalMetadata(parseHeaderValue(header, "topic")) ?? normalizeOptionalMetadata(parseHeaderValue(header, "task"));
7853
- return {
7854
- kind,
7855
- project,
7856
- sourceAgentClient: parseHeaderValue(header, "source_agent_client") ?? void 0,
7857
- status,
7858
- topic
7853
+ async function writeTeamsFile(config, contents) {
7854
+ const path = teamsFilePath(config);
7855
+ await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
7856
+ const serializable = {
7857
+ defaultTeam: contents.defaultTeam,
7858
+ teams: contents.teams,
7859
+ version: contents.version
7859
7860
  };
7861
+ await (0, import_promises4.writeFile)(path, `${JSON.stringify(serializable, void 0, 2)}
7862
+ `, { encoding: "utf8", mode: 384 });
7860
7863
  }
7861
- function parseHeaderValue(header, key) {
7862
- const prefix = `${key}:`;
7863
- return header.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim();
7864
- }
7865
- function isClearLegacyHandoffMemory(memory) {
7866
- if (/^kind:\s*/m.test(memory) || /^status:\s*/m.test(memory)) {
7867
- return false;
7868
- }
7869
- const trimmed = memory.trim();
7870
- if (trimmed.startsWith("HANDOFF\n")) {
7871
- return true;
7864
+ async function resolveTeam(config, requested) {
7865
+ const teamsFile = await readTeamsFile(config);
7866
+ const entries = Object.entries(teamsFile.teams);
7867
+ if (entries.length === 0) {
7868
+ throw new Error("No shared teams configured. Run: threadnote share init <remote-url>");
7872
7869
  }
7873
- if (!trimmed.startsWith("MEMORY\n")) {
7874
- return false;
7870
+ const wantName = requested ? normalizeTeamName(requested) : teamsFile.defaultTeam ?? entries[0][0];
7871
+ const found = teamsFile.teams[wantName];
7872
+ if (!found) {
7873
+ const known = entries.map(([name]) => name).join(", ");
7874
+ throw new Error(`Team "${wantName}" is not configured. Known teams: ${known}`);
7875
7875
  }
7876
- return /^(?:#+\s*)?(?:final\s+)?handoff(?:\s+update)?\b/i.test(memoryBody(trimmed));
7877
- }
7878
- function memoryBody(memory) {
7879
- const separatorIndex = memory.indexOf("\n\n");
7880
- return separatorIndex === -1 ? "" : memory.slice(separatorIndex + 2).trim();
7876
+ return { config: found, name: wantName };
7881
7877
  }
7882
- function inferLegacyProject(memory) {
7883
- const explicit = parseHeaderValue(memory, "project") ?? parseHeaderValue(memory, "repo") ?? parseHeaderValue(memory, "repo_path") ?? /\brepo(?:_path)?\s+([~/A-Za-z0-9_.:/-]+)/.exec(memory)?.[1];
7884
- if (!explicit) {
7885
- return "general";
7878
+ function shouldSetDefault(options, existing) {
7879
+ if (options.setDefault === true) {
7880
+ return true;
7886
7881
  }
7887
- const trimmed = explicit.trim().replace(/[`.,;]+$/g, "");
7888
- return trimmed.includes("/") ? (0, import_node_path4.basename)(trimmed) : trimmed;
7882
+ return existing.defaultTeam === void 0;
7889
7883
  }
7890
- function parseOptionalMemoryKind(value) {
7891
- if (!value) {
7892
- return void 0;
7884
+ async function assertWorktreeUsable(worktree) {
7885
+ if (!await exists(worktree)) {
7886
+ return;
7893
7887
  }
7894
- try {
7895
- return parseMemoryKind(value);
7896
- } catch (_err) {
7897
- return void 0;
7888
+ if (!await isDirectory(worktree)) {
7889
+ throw new Error(`Cannot use ${worktree} as a worktree: not a directory.`);
7898
7890
  }
7899
- }
7900
- function parseOptionalMemoryStatus(value) {
7901
- if (!value) {
7902
- return void 0;
7891
+ const entries = await (0, import_promises4.readdir)(worktree);
7892
+ if (entries.length > 0) {
7893
+ const preview = entries.slice(0, 5).join(", ");
7894
+ const suffix = entries.length > 5 ? `, +${entries.length - 5} more` : "";
7895
+ throw new Error(
7896
+ `Worktree ${worktree} is not empty (contains: ${preview}${suffix}). Move or remove its contents, then retry threadnote share init.`
7897
+ );
7903
7898
  }
7904
- try {
7905
- return parseMemoryStatus(value);
7906
- } catch (_err) {
7907
- return void 0;
7908
- }
7909
- }
7910
- function normalizeOptionalMetadata(value) {
7911
- const trimmed = value?.trim();
7912
- return trimmed ? trimmed : void 0;
7913
7899
  }
7914
- async function legacySourceAccounts(config, options) {
7915
- const explicitAccounts = options.sourceAccount?.filter((account) => account.trim().length > 0) ?? [];
7916
- if (explicitAccounts.length > 0) {
7917
- return uniqueStrings(explicitAccounts);
7918
- }
7919
- if (options.allAccounts === true) {
7920
- const accounts = await childDirectoryNames(localVikingDataRoot(config));
7921
- return accounts.filter((account) => !account.startsWith("_"));
7900
+ async function ingestWorktreeFiles(config, team, initialMode) {
7901
+ const ov = await openVikingCliForMode(false);
7902
+ const files = await walkMemoryFiles(team.worktree);
7903
+ for (const file of files) {
7904
+ const uri = workfileToVikingUri(config, team, file);
7905
+ await ensureSharedDirectoryChain(config, ov, uri, false);
7906
+ await ingestSingleFile(ov, config, uri, file, initialMode);
7922
7907
  }
7923
- return [config.account];
7908
+ return files.length;
7924
7909
  }
7925
- async function legacyMemoryCandidates(config, sourceAccounts) {
7926
- const candidates = [];
7927
- for (const sourceAccount of sourceAccounts) {
7928
- const sessionRoot = (0, import_node_path4.join)(localVikingDataRoot(config), sourceAccount, "session");
7929
- for (const sourceSession of await childDirectoryNames(sessionRoot)) {
7930
- const historyRoot = (0, import_node_path4.join)(sessionRoot, sourceSession, "history");
7931
- for (const sourceArchive of await childDirectoryNames(historyRoot)) {
7932
- if (!sourceArchive.startsWith("archive_")) {
7910
+ async function walkMemoryFiles(root) {
7911
+ const out = [];
7912
+ async function visit(path, depth) {
7913
+ let entries;
7914
+ try {
7915
+ entries = await (0, import_promises4.readdir)(path, { withFileTypes: true });
7916
+ } catch (err) {
7917
+ console.warn(`Skipping ${path} during shared-tree walk: ${err instanceof Error ? err.message : String(err)}`);
7918
+ return;
7919
+ }
7920
+ for (const entry of entries) {
7921
+ if (entry.name === ".git") {
7922
+ continue;
7923
+ }
7924
+ const full = (0, import_node_path4.join)(path, entry.name);
7925
+ if (entry.isDirectory()) {
7926
+ if (depth === 0 && !SHAREABLE_MEMORY_KIND_DIRS.includes(entry.name)) {
7933
7927
  continue;
7934
7928
  }
7935
- const sourcePath = (0, import_node_path4.join)(historyRoot, sourceArchive, "messages.jsonl");
7936
- for (const text of await legacyMemoryTexts(sourcePath)) {
7937
- candidates.push({
7938
- comparableHash: sha256(comparableMemoryText(text)),
7939
- hash: sha256(text),
7940
- sourceAccount,
7941
- sourceArchive,
7942
- sourceSession,
7943
- text
7944
- });
7945
- }
7929
+ await visit(full, depth + 1);
7930
+ continue;
7931
+ }
7932
+ if (!entry.isFile()) {
7933
+ continue;
7934
+ }
7935
+ if (depth === 0) {
7936
+ continue;
7937
+ }
7938
+ if (!entry.name.endsWith(".md")) {
7939
+ continue;
7946
7940
  }
7941
+ out.push(full);
7947
7942
  }
7948
7943
  }
7949
- return candidates.sort((left, right) => legacySourceLabel(left).localeCompare(legacySourceLabel(right)));
7944
+ await visit(root, 0);
7945
+ return out;
7950
7946
  }
7951
- async function legacyMemoryTexts(sourcePath) {
7952
- const raw = await readTextIfExists(sourcePath);
7953
- if (!raw) {
7954
- return [];
7947
+ function workfileToVikingUri(config, team, filePath) {
7948
+ const rel = (0, import_node_path4.relative)(team.worktree, filePath).split(import_node_path4.sep).join("/");
7949
+ return `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team.name}/${rel}`;
7950
+ }
7951
+ function isInSharedNamespace(config, uri) {
7952
+ return uri.startsWith(`viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/`);
7953
+ }
7954
+ function isInTeamNamespace(config, uri, team) {
7955
+ return uri.startsWith(`viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`);
7956
+ }
7957
+ function sharedUriFor(config, personalUri, team) {
7958
+ const prefix = `viking://user/${uriSegment(config.user)}/memories/`;
7959
+ if (!personalUri.startsWith(prefix)) {
7960
+ throw new Error(`Refusing to publish memory outside the current user namespace: ${personalUri}`);
7955
7961
  }
7956
- const memories = [];
7957
- for (const line of raw.split("\n")) {
7958
- const trimmedLine = line.trim();
7959
- if (!trimmedLine) {
7960
- continue;
7962
+ const rest = personalUri.slice(prefix.length);
7963
+ return `${prefix}${SHARED_SEGMENT}/${team}/${rest}`;
7964
+ }
7965
+ function personalUriFor(config, sharedUri, team) {
7966
+ const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`;
7967
+ if (!sharedUri.startsWith(prefix)) {
7968
+ throw new Error(`Refusing to unpublish a URI outside team "${team}" shared namespace: ${sharedUri}`);
7969
+ }
7970
+ const rest = sharedUri.slice(prefix.length);
7971
+ return `viking://user/${uriSegment(config.user)}/memories/${rest}`;
7972
+ }
7973
+ function vikingUriToWorktreeRelative(config, uri, team) {
7974
+ const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`;
7975
+ if (!uri.startsWith(prefix)) {
7976
+ throw new Error(`URI ${uri} is not inside team "${team}" shared subtree.`);
7977
+ }
7978
+ return uri.slice(prefix.length);
7979
+ }
7980
+ function stripPersonalProvenance(content) {
7981
+ const lines = content.split("\n");
7982
+ let headerEnd = lines.length;
7983
+ for (let index = 0; index < lines.length; index += 1) {
7984
+ if (lines[index].trim() === "") {
7985
+ headerEnd = index;
7986
+ break;
7961
7987
  }
7962
- try {
7963
- const parsed = JSON.parse(trimmedLine);
7964
- const text = legacyMessageText(parsed)?.trim();
7965
- if (text && isLegacyThreadnoteMemory(text)) {
7966
- memories.push(text);
7967
- }
7968
- } catch (_err) {
7988
+ }
7989
+ const cleaned = [];
7990
+ for (let index = 0; index < headerEnd; index += 1) {
7991
+ if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
7969
7992
  continue;
7970
7993
  }
7994
+ cleaned.push(lines[index]);
7971
7995
  }
7972
- return memories;
7973
- }
7974
- function legacyMessageText(value) {
7975
- if (!isJsonObject(value)) {
7976
- return void 0;
7996
+ for (let index = headerEnd; index < lines.length; index += 1) {
7997
+ cleaned.push(lines[index]);
7977
7998
  }
7978
- if (typeof value.content === "string") {
7979
- return value.content;
7999
+ return cleaned.join("\n");
8000
+ }
8001
+ async function readMemoryContent(config, ov, uri, dryRun) {
8002
+ const args = withIdentity(config, ["read", uri]);
8003
+ if (dryRun) {
8004
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
8005
+ return "<dry-run memory body>";
7980
8006
  }
7981
- if (!Array.isArray(value.parts)) {
7982
- return void 0;
8007
+ const result = await runCommand(ov, args);
8008
+ if (!result.stdout.trim()) {
8009
+ throw new Error(`Refusing to publish empty memory at ${uri}`);
7983
8010
  }
7984
- const parts = value.parts.map((part) => isJsonObject(part) && part.type === "text" && typeof part.text === "string" ? part.text : void 0).filter((text) => text !== void 0);
7985
- return parts.length > 0 ? parts.join("\n") : void 0;
7986
- }
7987
- function isLegacyThreadnoteMemory(text) {
7988
- return text.startsWith("MEMORY\n") || text.startsWith("HANDOFF\n");
7989
- }
7990
- async function existingDurableMemoryHashes(config) {
7991
- const hashes = /* @__PURE__ */ new Set();
7992
- await collectDurableMemoryHashes(localVikingDataRoot(config), hashes);
7993
- return hashes;
8011
+ return result.stdout;
7994
8012
  }
7995
- async function collectDurableMemoryHashes(root, hashes) {
7996
- let entries;
7997
- try {
7998
- entries = await (0, import_promises4.readdir)(root, { withFileTypes: true });
7999
- } catch (_err) {
8000
- return;
8001
- }
8002
- for (const entry of entries) {
8003
- const path = (0, import_node_path4.join)(root, entry.name);
8004
- if (entry.isDirectory()) {
8005
- await collectDurableMemoryHashes(path, hashes);
8013
+ async function ensureSharedDirectoryChain(config, ov, memoryUri, dryRun, options = {}) {
8014
+ const directoryUri = parentUri(memoryUri);
8015
+ for (const uri of sharedDirectoryChain(config, directoryUri)) {
8016
+ const args = withIdentity(config, ["stat", uri]);
8017
+ if (dryRun) {
8018
+ if (options.quiet !== true) {
8019
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
8020
+ }
8006
8021
  continue;
8007
8022
  }
8008
- if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md") || !isDurableMemoryPath(path)) {
8023
+ const statResult = await runCommand(ov, args, { allowFailure: true });
8024
+ if (statResult.exitCode === 0) {
8009
8025
  continue;
8010
8026
  }
8011
- const content = await readTextIfExists(path);
8012
- if (content) {
8013
- const trimmedContent = content.trim();
8014
- hashes.add(sha256(trimmedContent));
8015
- hashes.add(sha256(comparableMemoryText(trimmedContent)));
8027
+ if (options.quiet === true) {
8028
+ await runCommand(ov, withIdentity(config, ["mkdir", uri, "--description", "Threadnote shared memories."]));
8029
+ } else {
8030
+ await maybeRun(false, ov, withIdentity(config, ["mkdir", uri, "--description", "Threadnote shared memories."]));
8016
8031
  }
8017
8032
  }
8018
8033
  }
8019
- function isDurableMemoryPath(path) {
8020
- return path.split(import_node_path4.sep).includes("memories");
8034
+ function parentUri(uri) {
8035
+ const lastSlash = uri.lastIndexOf("/");
8036
+ return lastSlash === -1 ? uri : uri.slice(0, lastSlash);
8021
8037
  }
8022
- async function childDirectoryNames(path) {
8023
- let entries;
8024
- try {
8025
- entries = await (0, import_promises4.readdir)(path, { withFileTypes: true });
8026
- } catch (_err) {
8027
- return [];
8038
+ function sharedDirectoryChain(config, directoryUri) {
8039
+ const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/`;
8040
+ if (!directoryUri.startsWith(prefix)) {
8041
+ return [directoryUri];
8028
8042
  }
8029
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
8043
+ const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
8044
+ const chain = [];
8045
+ for (let index = 1; index <= parts.length; index += 1) {
8046
+ chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
8047
+ }
8048
+ return chain;
8030
8049
  }
8031
- async function readTextIfExists(path) {
8032
- try {
8033
- const pathStat = await (0, import_promises4.stat)(path);
8034
- if (!pathStat.isFile()) {
8035
- return void 0;
8050
+ async function writeMemoryFile(config, ov, uri, content, initialMode, dryRun, options = {}) {
8051
+ if (dryRun) {
8052
+ const args = withIdentity(config, [
8053
+ "write",
8054
+ uri,
8055
+ "--from-file",
8056
+ "<staged temp file>",
8057
+ "--mode",
8058
+ initialMode,
8059
+ "--wait",
8060
+ "--timeout",
8061
+ "120"
8062
+ ]);
8063
+ if (options.quiet !== true) {
8064
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
8036
8065
  }
8037
- return await (0, import_promises4.readFile)(path, "utf8");
8038
- } catch (_err) {
8039
- return void 0;
8066
+ return;
8040
8067
  }
8041
- }
8042
- function sensitiveMemoryReason(text) {
8043
- const patterns = [
8044
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
8045
- { name: "API key", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
8046
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
8047
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
8048
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ }
8049
- ];
8050
- return patterns.find((pattern) => pattern.regex.test(text))?.name;
8051
- }
8052
- function comparableMemoryText(text) {
8053
- const trimmed = text.trim();
8054
- if (!trimmed.startsWith("MEMORY\n")) {
8055
- return trimmed;
8068
+ const stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path4.join)((0, import_node_os4.tmpdir)(), "threadnote-share-"));
8069
+ const tempPath = (0, import_node_path4.join)(stagingDir, "body.txt");
8070
+ try {
8071
+ await (0, import_promises4.writeFile)(tempPath, content, { encoding: "utf8", mode: 384 });
8072
+ await writeOvFileWithRetry(config, ov, uri, tempPath, initialMode, options);
8073
+ } finally {
8074
+ await (0, import_promises4.rm)(stagingDir, { force: true, recursive: true });
8056
8075
  }
8057
- const separatorIndex = trimmed.indexOf("\n\n");
8058
- return separatorIndex === -1 ? trimmed : trimmed.slice(separatorIndex + 2).trim();
8059
- }
8060
- function legacySourceLabel(candidate) {
8061
- return `${candidate.sourceAccount}/${candidate.sourceSession}/${candidate.sourceArchive}`;
8062
- }
8063
- function localVikingDataRoot(config) {
8064
- return (0, import_node_path4.join)(config.agentContextHome, "data", "viking");
8065
- }
8066
- function localUserMemoriesRoot(config) {
8067
- return (0, import_node_path4.join)(localVikingDataRoot(config), config.account, "user", uriSegment(config.user), "memories");
8068
- }
8069
- function uniqueStrings(values) {
8070
- return [...new Set(values)].sort();
8071
8076
  }
8072
- function isResourceBusy(stderr, stdout) {
8073
- const output2 = `${stderr}
8074
- ${stdout}`.toLowerCase();
8075
- return output2.includes("resource is busy") || output2.includes("resource is being processed");
8076
- }
8077
- async function buildHandoff(options) {
8078
- const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]) ?? getInvocationCwd();
8079
- const branch = await gitValue(["branch", "--show-current"], repoRoot) ?? "unknown";
8080
- const status = await gitValue(["status", "--short"], repoRoot) ?? "";
8081
- const diffStat = await gitValue(["diff", "--stat", "HEAD"], repoRoot) ?? "";
8082
- const touchedFiles = await gitTouchedFiles(repoRoot);
8083
- const repoName = (0, import_node_path4.basename)(repoRoot);
8084
- const metadata = {
8085
- kind: "handoff",
8086
- project: normalizeOptionalMetadata(options.project) ?? repoName,
8087
- sourceAgentClient: options.sourceAgentClient ?? "codex",
8088
- status: "active",
8089
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8090
- topic: normalizeOptionalMetadata(options.topic)
8091
- };
8092
- const bodyText = [
8093
- `repo: ${repoName}`,
8094
- `repo_path: ${repoRoot}`,
8095
- `branch: ${branch || "unknown"}`,
8096
- `task: ${options.task ?? "unspecified"}`,
8097
- "",
8098
- "files_touched:",
8099
- formatBlock(touchedFiles, "- none"),
8100
- "",
8101
- "git_status:",
8102
- formatBlock(status, "- clean"),
8103
- "",
8104
- "diff_stat:",
8105
- formatBlock(diffStat, "- none"),
8106
- "",
8107
- "tests:",
8108
- options.tests ?? "- not recorded",
8109
- "",
8110
- "blockers:",
8111
- options.blockers ?? "- none recorded",
8112
- "",
8113
- "next_step:",
8114
- options.nextStep ?? "- inspect the current repo state and continue from this handoff"
8115
- ].join("\n");
8116
- return { bodyText, metadata };
8117
- }
8118
- async function gitTouchedFiles(cwd) {
8119
- const changedFiles = await gitValue(["diff", "--name-only", "HEAD"], cwd);
8120
- const untrackedFiles = await gitValue(["ls-files", "--others", "--exclude-standard"], cwd);
8121
- const files = /* @__PURE__ */ new Set();
8122
- for (const value of [changedFiles, untrackedFiles]) {
8123
- for (const line of (value ?? "").split("\n")) {
8124
- const trimmed = line.trim();
8125
- if (trimmed) {
8126
- files.add(trimmed);
8077
+ async function writeOvFileWithRetry(config, ov, uri, fromFile, initialMode, options = {}) {
8078
+ const maxAttempts = 4;
8079
+ const existedBeforeWrite = await vikingResourceExists(ov, config, uri);
8080
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
8081
+ const existsNow = attempt === 0 ? existedBeforeWrite : await vikingResourceExists(ov, config, uri);
8082
+ const ourWriteLanded = existsNow && !existedBeforeWrite;
8083
+ const mode = attempt === 0 ? initialMode : ourWriteLanded ? "replace" : initialMode;
8084
+ const args = withIdentity(config, [
8085
+ "write",
8086
+ uri,
8087
+ "--from-file",
8088
+ fromFile,
8089
+ "--mode",
8090
+ mode,
8091
+ "--wait",
8092
+ "--timeout",
8093
+ "120"
8094
+ ]);
8095
+ if (options.quiet !== true) {
8096
+ console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
8097
+ }
8098
+ const result = await runCommand(ov, args, { allowFailure: true });
8099
+ if (result.exitCode === 0) {
8100
+ if (options.quiet !== true && result.stdout.trim()) {
8101
+ console.log(result.stdout.trim());
8102
+ }
8103
+ if (options.quiet !== true && result.stderr.trim()) {
8104
+ console.error(result.stderr.trim());
8105
+ }
8106
+ return;
8107
+ }
8108
+ if (isTransientOvFailure(result.stderr, result.stdout) && await vikingResourceExists(ov, config, uri) && !existedBeforeWrite) {
8109
+ if (options.quiet !== true) {
8110
+ console.log(
8111
+ "OpenViking accepted the write but returned an error before the wait completed; draining the queue."
8112
+ );
8127
8113
  }
8114
+ await waitForOvQueue(ov, config, options);
8115
+ return;
8116
+ }
8117
+ if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
8118
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
8119
+ }
8120
+ if (isResourceBusyFailure(result.stderr, result.stdout)) {
8121
+ await waitForOvQueue(ov, config, options);
8122
+ } else {
8123
+ await sleep(1e3 * (attempt + 1));
8128
8124
  }
8129
8125
  }
8130
- return [...files].sort().join("\n");
8131
8126
  }
8132
- function formatBlock(value, emptyValue) {
8133
- const trimmed = value.trim();
8134
- if (!trimmed) {
8135
- return emptyValue;
8127
+ async function waitForOvQueue(ov, config, options = {}) {
8128
+ const result = await runCommand(ov, withIdentity(config, ["wait", "--timeout", "120"]), { allowFailure: true });
8129
+ if (options.quiet !== true && result.stdout.trim()) {
8130
+ console.log(result.stdout.trim());
8131
+ }
8132
+ if (options.quiet !== true && result.stderr.trim()) {
8133
+ console.error(result.stderr.trim());
8136
8134
  }
8137
- return trimmed.split("\n").map((line) => `- ${line}`).join("\n");
8138
8135
  }
8139
-
8140
- // src/update-check.ts
8141
- var import_node_child_process2 = require("node:child_process");
8142
- var import_promises5 = require("node:fs/promises");
8143
- var import_node_path5 = require("node:path");
8144
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
8145
- var NPM_LATEST_URL = "https://registry.npmjs.org/threadnote/latest";
8146
- var FETCH_TIMEOUT_MS = 3e3;
8147
- async function checkForThreadnoteUpdate(args) {
8148
- if (args.currentVersion === "unknown") {
8149
- return void 0;
8136
+ function isTransientOvFailure(stderr, stdout) {
8137
+ const output2 = `${stderr}
8138
+ ${stdout}`.toLowerCase();
8139
+ return output2.includes("resource is busy") || output2.includes("resource is being processed") || output2.includes("network error") || output2.includes("error sending request") || output2.includes("http request failed") || output2.includes("connection refused") || output2.includes("connection reset") || output2.includes("timed out");
8140
+ }
8141
+ function isResourceBusyFailure(stderr, stdout) {
8142
+ const output2 = `${stderr}
8143
+ ${stdout}`.toLowerCase();
8144
+ return output2.includes("resource is busy") || output2.includes("resource is being processed");
8145
+ }
8146
+ async function ingestSingleFile(ov, config, uri, filePath, initialMode, options = {}) {
8147
+ const content = await (0, import_promises4.readFile)(filePath, "utf8");
8148
+ await writeMemoryFile(config, ov, uri, content, initialMode, false, options);
8149
+ }
8150
+ async function removeWithRollback(config, ov, sourceUri, rollbackUri, worktree, dryRun, label) {
8151
+ try {
8152
+ await removeMemoryUri(config, ov, sourceUri, dryRun);
8153
+ } catch (sourceErr) {
8154
+ if (dryRun) {
8155
+ throw sourceErr;
8156
+ }
8157
+ console.error(
8158
+ `Source removal failed during ${label}; rolling back ${rollbackUri} so the system is back to the pre-${label} state.`
8159
+ );
8160
+ try {
8161
+ await removeMemoryUri(config, ov, rollbackUri, false);
8162
+ } catch (rollbackErr) {
8163
+ console.error(
8164
+ `Rollback of ${rollbackUri} also failed. Manual cleanup needed via: threadnote forget ${rollbackUri}
8165
+ Rollback error: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`
8166
+ );
8167
+ }
8168
+ await bestEffortRemoveWorktreeFile(rollbackUri, worktree, label);
8169
+ throw sourceErr;
8150
8170
  }
8151
- const cached = await readUpdateCache(args.cachePath);
8152
- if (cached && isCacheFresh(cached)) {
8153
- return compareVersions(args.currentVersion, cached.latestVersion);
8171
+ }
8172
+ async function bestEffortRemoveWorktreeFile(rollbackUri, worktree, label) {
8173
+ if (label !== "publish") {
8174
+ return;
8154
8175
  }
8155
- const fresh = await fetchLatestVersion();
8156
- if (fresh) {
8157
- await writeUpdateCache(args.cachePath, {
8158
- checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
8159
- latestVersion: fresh,
8160
- version: 1
8161
- });
8162
- return compareVersions(args.currentVersion, fresh);
8176
+ const prefix = "viking://";
8177
+ if (!rollbackUri.startsWith(prefix)) {
8178
+ return;
8163
8179
  }
8164
- if (cached) {
8165
- return compareVersions(args.currentVersion, cached.latestVersion);
8180
+ const parts = rollbackUri.slice(prefix.length).split("/");
8181
+ const sharedIndex = parts.indexOf("shared");
8182
+ if (sharedIndex === -1 || sharedIndex + 2 >= parts.length) {
8183
+ return;
8166
8184
  }
8167
- return void 0;
8185
+ const relative3 = parts.slice(sharedIndex + 2).join("/");
8186
+ if (!relative3) {
8187
+ return;
8188
+ }
8189
+ await (0, import_promises4.rm)((0, import_node_path4.join)(worktree, relative3), { force: true });
8168
8190
  }
8169
- function spawnDetachedAutoUpdate() {
8170
- try {
8171
- const entry = process.argv[1];
8172
- if (typeof entry !== "string" || entry.length === 0) {
8173
- return;
8191
+ async function removeMemoryUri(config, ov, uri, dryRun, options = {}) {
8192
+ const args = withIdentity(config, ["rm", uri]);
8193
+ if (dryRun) {
8194
+ if (options.quiet !== true) {
8195
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
8174
8196
  }
8175
- const child = (0, import_node_child_process2.spawn)(process.execPath, [entry, "update", "--yes"], {
8176
- detached: true,
8177
- stdio: "ignore"
8178
- });
8179
- child.unref();
8180
- } catch {
8197
+ return;
8181
8198
  }
8182
- }
8183
- function compareVersions(currentVersion, latestVersion) {
8184
- return {
8185
- currentVersion,
8186
- latestVersion,
8187
- outdated: isNewerVersion(latestVersion, currentVersion)
8188
- };
8189
- }
8190
- function isNewerVersion(candidate, baseline) {
8191
- const candidateParts = parseVersion(candidate);
8192
- const baselineParts = parseVersion(baseline);
8193
- for (let index = 0; index < 3; index += 1) {
8194
- if (candidateParts[index] !== baselineParts[index]) {
8195
- return candidateParts[index] > baselineParts[index];
8199
+ const maxAttempts = 4;
8200
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
8201
+ const result = await runCommand(ov, args, { allowFailure: true });
8202
+ if (result.exitCode === 0) {
8203
+ if (options.quiet !== true && result.stdout.trim()) {
8204
+ console.log(result.stdout.trim());
8205
+ }
8206
+ return;
8207
+ }
8208
+ if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
8209
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
8210
+ }
8211
+ if (isResourceBusyFailure(result.stderr, result.stdout)) {
8212
+ await waitForOvQueue(ov, config, options);
8213
+ } else {
8214
+ await sleep(1e3 * (attempt + 1));
8196
8215
  }
8197
8216
  }
8198
- return false;
8199
8217
  }
8200
- function parseVersion(value) {
8201
- const parts = value.split(".").map((part) => parseInt(part, 10));
8202
- return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
8218
+ async function vikingResourceExists(ov, config, uri) {
8219
+ const result = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
8220
+ return result.exitCode === 0;
8203
8221
  }
8204
- function isCacheFresh(cache) {
8205
- const checkedAt = new Date(cache.checkedAt).getTime();
8206
- return Number.isFinite(checkedAt) && Date.now() - checkedAt < CACHE_TTL_MS;
8222
+ async function hasUncommittedChanges(worktree) {
8223
+ const result = await runCommand("git", ["-C", worktree, "status", "--porcelain"], { allowFailure: true });
8224
+ return result.stdout.trim().length > 0;
8207
8225
  }
8208
- async function readUpdateCache(cachePath) {
8209
- try {
8210
- const raw = await (0, import_promises5.readFile)(cachePath, "utf8");
8211
- const parsed = JSON.parse(raw);
8212
- if (parsed.version !== 1 || typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
8213
- return void 0;
8214
- }
8215
- return { checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion, version: 1 };
8216
- } catch {
8226
+ async function gitOutput(worktree, args, dryRun) {
8227
+ if (dryRun) {
8217
8228
  return void 0;
8218
8229
  }
8219
- }
8220
- async function writeUpdateCache(cachePath, contents) {
8221
- try {
8222
- await (0, import_promises5.mkdir)((0, import_node_path5.dirname)(cachePath), { recursive: true });
8223
- await (0, import_promises5.writeFile)(cachePath, `${JSON.stringify(contents)}
8224
- `, { encoding: "utf8", mode: 384 });
8225
- } catch {
8226
- }
8227
- }
8228
- async function fetchLatestVersion() {
8229
- try {
8230
- const response = await fetch(NPM_LATEST_URL, {
8231
- headers: { accept: "application/json" },
8232
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
8233
- });
8234
- if (!response.ok) {
8235
- return void 0;
8236
- }
8237
- const data = await response.json();
8238
- return typeof data.version === "string" && data.version.length > 0 ? data.version : void 0;
8239
- } catch {
8230
+ const result = await runCommand("git", ["-C", worktree, ...args], { allowFailure: true });
8231
+ if (result.exitCode !== 0) {
8240
8232
  return void 0;
8241
8233
  }
8234
+ return result.stdout.trim();
8242
8235
  }
8243
-
8244
- // src/version.ts
8245
- var import_node_fs4 = require("node:fs");
8246
- var import_node_path6 = require("node:path");
8247
- var cachedVersion;
8248
- function getThreadnoteVersion() {
8249
- if (cachedVersion !== void 0) {
8250
- return cachedVersion;
8251
- }
8252
- try {
8253
- const packageJsonPath = (0, import_node_path6.join)(__dirname, "..", "package.json");
8254
- const parsed = JSON.parse((0, import_node_fs4.readFileSync)(packageJsonPath, "utf8"));
8255
- cachedVersion = typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "unknown";
8256
- } catch {
8257
- cachedVersion = "unknown";
8258
- }
8259
- return cachedVersion;
8260
- }
8261
-
8262
- // src/hooks.ts
8263
- var MANAGED_HOOKS = [
8264
- {
8265
- event: "PreCompact",
8266
- command: HOOK_PRE_COMPACT_COMMAND,
8267
- description: "Auto-store a handoff snapshot before context compaction so the next turn can recall it."
8268
- },
8269
- {
8270
- event: "SessionStart",
8271
- command: HOOK_SESSION_START_COMMAND,
8272
- description: "Pre-load the latest handoff for the current repo into the new session context."
8273
- }
8274
- ];
8275
- async function runHooksInstall(config, agent, options) {
8276
- const apply = options.apply === true && options.dryRun !== true;
8277
- const remove = options.remove === true;
8278
- switch (agent) {
8279
- case "claude":
8280
- await runClaudeHooksInstall({ apply, remove });
8281
- return;
8282
- case "codex":
8283
- printCodexHooksNotice(remove);
8284
- return;
8285
- case "cursor":
8286
- printNoHooksSupported("cursor", remove);
8287
- return;
8288
- case "copilot":
8289
- printNoHooksSupported("copilot", remove);
8290
- return;
8291
- }
8292
- }
8293
- async function runClaudeHooksInstall(options) {
8294
- const path = expandPath(CLAUDE_SETTINGS_PATH);
8295
- const existingRaw = await exists(path) ? await (0, import_promises6.readFile)(path, "utf8") : "{}";
8296
- const parsed = parseJsonConfigObject(existingRaw) ?? {};
8297
- const next = options.remove ? withoutThreadnoteHooks(parsed) : withThreadnoteHooks(parsed);
8298
- const before = JSON.stringify(parsed);
8299
- const after = JSON.stringify(next);
8300
- if (before === after) {
8301
- console.log(`Claude hooks already ${options.remove ? "absent" : "managed"} in ${path}.`);
8302
- return;
8303
- }
8304
- console.log(`${options.apply ? "Updating" : "Would update"} ${path}:`);
8305
- for (const entry of MANAGED_HOOKS) {
8306
- console.log(` ${options.remove ? "-" : "+"} ${entry.event}: ${entry.command}`);
8307
- }
8308
- if (!options.apply) {
8309
- console.log("\nRe-run with --apply to actually modify the file.");
8310
- return;
8236
+ async function listChangedFiles(worktree, beforeRev, afterRev) {
8237
+ const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
8238
+ allowFailure: true
8239
+ });
8240
+ if (result.exitCode !== 0) {
8241
+ return [];
8311
8242
  }
8312
- await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(path), { recursive: true });
8313
- const serialized = `${JSON.stringify(next, void 0, 2)}
8314
- `;
8315
- await (0, import_promises6.writeFile)(path, serialized, { encoding: "utf8", mode: 384 });
8316
- await (0, import_promises6.chmod)(path, 384);
8317
- console.log(`${options.remove ? "Removed" : "Installed"} threadnote-managed Claude hooks.`);
8318
- }
8319
- function withThreadnoteHooks(input2) {
8320
- const hooks = ensureMutableObject(input2.hooks);
8321
- for (const entry of MANAGED_HOOKS) {
8322
- const list = ensureMutableArray(hooks[entry.event]).filter((item) => !isManagedThreadnoteEntry(item));
8323
- list.push({
8324
- [THREADNOTE_HOOK_MARKER]: THREADNOTE_HOOK_MARKER_VALUE,
8325
- matcher: "",
8326
- hooks: [{ type: "command", command: entry.command }]
8327
- });
8328
- hooks[entry.event] = list;
8243
+ const entries = result.stdout.split("\0").filter((part) => part.length > 0);
8244
+ const changes = [];
8245
+ for (let index = 0; index < entries.length; ) {
8246
+ const raw = entries[index];
8247
+ const head = raw.slice(0, 1);
8248
+ if (head === "R" || head === "C") {
8249
+ const oldRel = entries[index + 1];
8250
+ const newRel = entries[index + 2];
8251
+ if (oldRel && newRel) {
8252
+ changes.push({ path: (0, import_node_path4.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
8253
+ changes.push({ path: (0, import_node_path4.join)(worktree, newRel), relativePath: newRel, status: "added" });
8254
+ }
8255
+ index += 3;
8256
+ continue;
8257
+ }
8258
+ const rel = entries[index + 1];
8259
+ if (rel) {
8260
+ const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
8261
+ changes.push({ path: (0, import_node_path4.join)(worktree, rel), relativePath: rel, status });
8262
+ }
8263
+ index += 2;
8329
8264
  }
8330
- return { ...input2, hooks };
8265
+ return changes;
8331
8266
  }
8332
- function withoutThreadnoteHooks(input2) {
8333
- if (!isJsonObject(input2.hooks)) {
8334
- return input2;
8335
- }
8336
- const hooks = {};
8337
- for (const [event, value] of Object.entries(input2.hooks)) {
8338
- if (!Array.isArray(value)) {
8339
- hooks[event] = value;
8267
+ async function applyChangesToOpenViking(config, team, changes, options = {}) {
8268
+ const ov = await openVikingCliForMode(false);
8269
+ for (const change of changes) {
8270
+ if (!change.relativePath.endsWith(".md")) {
8340
8271
  continue;
8341
8272
  }
8342
- const filtered = value.filter((item) => !isManagedThreadnoteEntry(item));
8343
- if (filtered.length > 0) {
8344
- hooks[event] = filtered;
8273
+ const firstSegment = change.relativePath.split("/")[0];
8274
+ if (!SHAREABLE_MEMORY_KIND_DIRS.includes(firstSegment)) {
8275
+ continue;
8345
8276
  }
8277
+ const uri = workfileToVikingUri(config, team, change.path);
8278
+ if (change.status === "removed") {
8279
+ await removeMemoryUri(config, ov, uri, false, options);
8280
+ continue;
8281
+ }
8282
+ if (!await isFile(change.path)) {
8283
+ continue;
8284
+ }
8285
+ const ovHasResource = await vikingResourceExists(ov, config, uri);
8286
+ if (ovHasResource) {
8287
+ const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
8288
+ if (options.quiet !== true) {
8289
+ console.warn(`share sync: ${uri}: ${reason}.`);
8290
+ }
8291
+ }
8292
+ await ensureSharedDirectoryChain(config, ov, uri, false, options);
8293
+ const writeMode = ovHasResource ? "replace" : "create";
8294
+ await ingestSingleFile(ov, config, uri, change.path, writeMode, options);
8346
8295
  }
8347
- if (Object.keys(hooks).length === 0) {
8348
- const next = { ...input2 };
8349
- delete next.hooks;
8350
- return next;
8351
- }
8352
- return { ...input2, hooks };
8353
- }
8354
- function isManagedThreadnoteEntry(value) {
8355
- return isJsonObject(value) && value[THREADNOTE_HOOK_MARKER] === THREADNOTE_HOOK_MARKER_VALUE;
8356
8296
  }
8357
- function ensureMutableObject(value) {
8358
- return isJsonObject(value) ? { ...value } : {};
8359
- }
8360
- function ensureMutableArray(value) {
8361
- return Array.isArray(value) ? [...value] : [];
8297
+
8298
+ // src/memory.ts
8299
+ function parseMemoryKind(value) {
8300
+ if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
8301
+ return value;
8302
+ }
8303
+ throw new Error(`Unsupported memory kind "${value}". Expected durable, handoff, incident, preference, or smoke.`);
8362
8304
  }
8363
- function printCodexHooksNotice(remove) {
8364
- if (remove) {
8365
- console.log("Codex CLI does not expose a managed hook surface today, so there is nothing to remove.");
8366
- return;
8305
+ function parseMemoryStatus(value) {
8306
+ if (["active", "archived", "superseded"].includes(value)) {
8307
+ return value;
8367
8308
  }
8368
- console.log(
8369
- [
8370
- "Codex CLI does not currently expose lifecycle hooks (no PreCompact or SessionStart analog).",
8371
- "Threadnote already installs Codex user instructions at ~/.codex/AGENTS.md; that remains the active guidance surface.",
8372
- "If a future Codex release adds hook events, threadnote will pick them up via `install-hooks codex`."
8373
- ].join("\n")
8374
- );
8309
+ throw new Error(`Unsupported memory status "${value}". Expected active, archived, or superseded.`);
8375
8310
  }
8376
- function printNoHooksSupported(agent, remove) {
8377
- if (remove) {
8378
- console.log(`${agent} does not expose a hook surface; nothing to remove.`);
8379
- return;
8311
+ async function runRemember(config, options) {
8312
+ const text = await getInputText(options.text, options.stdin === true);
8313
+ if (!text.trim()) {
8314
+ throw new Error("Provide memory text with --text or --stdin.");
8380
8315
  }
8381
- console.log(
8382
- [
8383
- `${agent} does not expose agent-mode hooks today.`,
8384
- "Threadnote already installs user-level instructions for this agent; that remains the active guidance surface.",
8385
- "Hooks support will be added if the agent gains a hook surface upstream."
8386
- ].join("\n")
8387
- );
8316
+ const metadata = {
8317
+ kind: options.kind ?? "durable",
8318
+ project: normalizeOptionalMetadata(options.project),
8319
+ sourceAgentClient: options.sourceAgentClient ?? "codex",
8320
+ status: options.status ?? "active",
8321
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8322
+ topic: normalizeOptionalMetadata(options.topic)
8323
+ };
8324
+ await storeMemory(config, {
8325
+ bodyText: text.trim(),
8326
+ dryRun: options.dryRun === true,
8327
+ metadata,
8328
+ replaceUri: options.replace,
8329
+ title: "MEMORY"
8330
+ });
8388
8331
  }
8389
- async function hasManagedClaudeHooks() {
8390
- const path = expandPath(CLAUDE_SETTINGS_PATH);
8391
- if (!await exists(path)) {
8392
- return false;
8332
+ async function runMigrateMemories(config, options) {
8333
+ const dryRun = options.dryRun === true;
8334
+ const limit = options.limit ? parsePositiveInteger(options.limit, "migration limit") : void 0;
8335
+ const sourceAccounts = await legacySourceAccounts(config, options);
8336
+ if (sourceAccounts.length === 0) {
8337
+ console.log("No local OpenViking accounts found to scan.");
8338
+ return;
8393
8339
  }
8394
- const raw = await (0, import_promises6.readFile)(path, "utf8");
8395
- const parsed = parseJsonConfigObject(raw);
8396
- if (!parsed || !isJsonObject(parsed.hooks)) {
8397
- return false;
8340
+ const candidates = await legacyMemoryCandidates(config, sourceAccounts);
8341
+ const existingHashes = await existingDurableMemoryHashes(config);
8342
+ const ov = await openVikingCliForMode(dryRun);
8343
+ const migrationPath = (0, import_node_path5.join)(config.agentContextHome, "legacy-memory-migration.txt");
8344
+ let duplicateCount = 0;
8345
+ let migratedCount = 0;
8346
+ let sensitiveCount = 0;
8347
+ if (!dryRun && candidates.length > 0) {
8348
+ await ensureDurableMemoryDirectory(ov, config);
8398
8349
  }
8399
- for (const value of Object.values(parsed.hooks)) {
8400
- if (!Array.isArray(value)) {
8401
- continue;
8350
+ try {
8351
+ for (const candidate of candidates) {
8352
+ if (existingHashes.has(candidate.hash)) {
8353
+ duplicateCount += 1;
8354
+ continue;
8355
+ }
8356
+ if (existingHashes.has(candidate.comparableHash)) {
8357
+ duplicateCount += 1;
8358
+ continue;
8359
+ }
8360
+ const sensitiveReason = sensitiveMemoryReason(candidate.text);
8361
+ if (sensitiveReason) {
8362
+ sensitiveCount += 1;
8363
+ console.log(
8364
+ `SKIP ${legacySourceLabel(candidate)}: possible ${sensitiveReason}; inspect the source archive manually if needed.`
8365
+ );
8366
+ continue;
8367
+ }
8368
+ if (limit !== void 0 && migratedCount >= limit) {
8369
+ break;
8370
+ }
8371
+ const memoryUri = migratedDurableMemoryUri(config, candidate.hash);
8372
+ if (!dryRun && await vikingResourceExists2(ov, config, memoryUri)) {
8373
+ duplicateCount += 1;
8374
+ existingHashes.add(candidate.hash);
8375
+ continue;
8376
+ }
8377
+ console.log(`${dryRun ? "Would migrate" : "Migrating"} ${legacySourceLabel(candidate)} -> ${memoryUri}`);
8378
+ if (!dryRun) {
8379
+ await (0, import_promises5.writeFile)(migrationPath, candidate.text, { encoding: "utf8", mode: 384 });
8380
+ await (0, import_promises5.chmod)(migrationPath, 384);
8381
+ await writeDurableMemoryFile(ov, config, memoryUri, migrationPath, "create");
8382
+ existingHashes.add(candidate.hash);
8383
+ }
8384
+ migratedCount += 1;
8402
8385
  }
8403
- if (value.some((item) => isManagedThreadnoteEntry(item))) {
8404
- return true;
8386
+ } finally {
8387
+ if (!dryRun) {
8388
+ await (0, import_promises5.rm)(migrationPath, { force: true });
8405
8389
  }
8406
8390
  }
8407
- return false;
8408
- }
8409
- async function runPreCompactHook(config, options = {}) {
8410
- try {
8411
- const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]);
8412
- const project = repoRoot ? (0, import_node_path7.basename)(repoRoot) : "general";
8413
- await runHandoff(config, {
8414
- blockers: "- none recorded",
8415
- dryRun: options.dryRun === true,
8416
- nextStep: "Continue from this auto-snapshot. A manual `threadnote handoff` will produce a richer write-up if you have more context.",
8417
- project,
8418
- sourceAgentClient: "claude",
8419
- task: "Auto-snapshot captured at Claude PreCompact (deterministic safety net before context compaction).",
8420
- tests: "- not recorded (auto-snapshot)",
8421
- topic: HOOK_AUTO_PRECOMPACT_TOPIC
8422
- });
8423
- } catch (err) {
8424
- process.stderr.write(
8425
- `threadnote pre-compact-hook: snapshot skipped (${err instanceof Error ? err.message : String(err)})
8426
- `
8427
- );
8428
- }
8429
- }
8430
- async function runSessionStartHook(config, options = {}) {
8431
- try {
8432
- const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]);
8433
- if (!repoRoot) {
8434
- return;
8435
- }
8436
- const project = (0, import_node_path7.basename)(repoRoot);
8437
- await emitUpdateBannerIfOutdated(config);
8438
- process.stdout.write(`## Threadnote \u2014 latest context for ${project}
8439
-
8440
- `);
8441
- await runRecall(config, {
8442
- dryRun: options.dryRun === true,
8443
- inferScope: true,
8444
- nodeLimit: "5",
8445
- query: `${project} latest handoff durable feature memory`
8446
- });
8447
- } catch (err) {
8448
- process.stderr.write(
8449
- `threadnote session-start-hook: recall skipped (${err instanceof Error ? err.message : String(err)})
8450
- `
8451
- );
8452
- }
8453
- }
8454
- async function emitUpdateBannerIfOutdated(config) {
8455
- try {
8456
- const result = await checkForThreadnoteUpdate({
8457
- cachePath: (0, import_node_path7.join)(config.agentContextHome, ".update-state.json"),
8458
- currentVersion: getThreadnoteVersion()
8459
- });
8460
- if (!result || !result.outdated) {
8461
- return;
8462
- }
8463
- if (process.env.THREADNOTE_AUTO_UPDATE === "1") {
8464
- process.stdout.write(
8465
- `[threadnote] v${result.latestVersion} available (current v${result.currentVersion}). Auto-updating in the background; the new version takes effect next session.
8466
-
8467
- `
8468
- );
8469
- spawnDetachedAutoUpdate();
8470
- return;
8471
- }
8472
- process.stdout.write(
8473
- `[threadnote] v${result.latestVersion} available (current v${result.currentVersion}). Run: threadnote update
8474
-
8475
- `
8476
- );
8477
- } catch {
8478
- }
8391
+ console.log(
8392
+ [
8393
+ `Migration summary: ${migratedCount} ${dryRun ? "would be migrated" : "migrated"}`,
8394
+ `${duplicateCount} duplicate(s) skipped`,
8395
+ `${sensitiveCount} sensitive-looking item(s) skipped`,
8396
+ `${candidates.length} legacy Threadnote item(s) scanned`,
8397
+ `source account(s): ${sourceAccounts.join(", ")}`
8398
+ ].join("; ")
8399
+ );
8479
8400
  }
8480
-
8481
- // src/seeding.ts
8482
- var import_promises7 = require("node:fs/promises");
8483
- var import_node_path8 = require("node:path");
8484
- async function runSeed(config, options) {
8485
- const manifest = await readSeedManifest(config.manifestPath);
8486
- const ignorePatterns = await loadIgnorePatterns();
8487
- const ov = await openVikingCliForMode(options.dryRun === true);
8488
- const statePath = (0, import_node_path8.join)(config.agentContextHome, SEED_STATE_FILE);
8489
- const state = options.force === true ? { files: {}, version: 1 } : await readSeedState(statePath);
8490
- const projects = filterProjects(manifest.projects, options.only);
8491
- let importedCount = 0;
8401
+ async function runMigrateLifecycle(config, options) {
8402
+ const dryRun = options.dryRun === true || options.apply !== true;
8403
+ const limit = options.limit ? parsePositiveInteger(options.limit, "lifecycle migration limit") : void 0;
8404
+ const ov = await openVikingCliForMode(dryRun);
8405
+ const candidates = await legacyLifecycleHandoffCandidates(config);
8406
+ const migrationPath = (0, import_node_path5.join)(config.agentContextHome, "lifecycle-memory-migration.txt");
8407
+ let existingCount = 0;
8408
+ let migratedCount = 0;
8492
8409
  let skippedCount = 0;
8493
- let unchangedCount = 0;
8494
- for (const project of projects) {
8495
- const projectRoot = expandPath(project.path);
8496
- if (!await exists(projectRoot)) {
8497
- console.log(`WARN project missing: ${project.name} (${projectRoot})`);
8498
- continue;
8499
- }
8500
- const candidates = await collectSeedCandidates(project, projectRoot, ignorePatterns);
8410
+ try {
8501
8411
  for (const candidate of candidates) {
8502
- const fileStat = await statSeedFile(candidate.filePath);
8503
- const recorded = state.files[candidate.destinationUri];
8504
- if (fileStat && recorded && recorded.mtimeMs === fileStat.mtimeMs && recorded.size === fileStat.size) {
8505
- unchangedCount += 1;
8506
- continue;
8507
- }
8508
- const importPath = await prepareSeedFile(config, candidate, options.dryRun === true);
8509
- if (!importPath) {
8510
- skippedCount += 1;
8511
- continue;
8412
+ if (limit !== void 0 && migratedCount >= limit) {
8413
+ break;
8512
8414
  }
8513
- const args = withIdentity(config, ["add-resource", importPath, "--to", candidate.destinationUri, "--wait"]);
8514
- await maybeRun(options.dryRun === true, ov, args);
8515
- importedCount += 1;
8516
- if (fileStat && options.dryRun !== true) {
8517
- state.files[candidate.destinationUri] = { mtimeMs: fileStat.mtimeMs, size: fileStat.size };
8415
+ const destinationUri = lifecycleMigrationUri(config, candidate.metadata, sha256(candidate.original.trim()));
8416
+ const migratedMemory = formatMemoryDocument(
8417
+ "HANDOFF",
8418
+ candidate.metadata,
8419
+ ["Migrated legacy handoff from the historical events trail.", "", candidate.original.trim()].join("\n")
8420
+ );
8421
+ console.log(`${dryRun ? "Would migrate" : "Migrating"} ${candidate.sourceUri} -> ${destinationUri}`);
8422
+ if (!dryRun) {
8423
+ if (await vikingResourceExists2(ov, config, destinationUri)) {
8424
+ existingCount += 1;
8425
+ console.log(`Archived copy already exists; cleaning up legacy source: ${candidate.sourceUri}`);
8426
+ } else {
8427
+ await (0, import_promises5.writeFile)(migrationPath, migratedMemory, { encoding: "utf8", mode: 384 });
8428
+ await (0, import_promises5.chmod)(migrationPath, 384);
8429
+ await ensureMemoryDirectory(ov, config, memoryDirectoryUri(config, candidate.metadata));
8430
+ await writeDurableMemoryFile(ov, config, destinationUri, migrationPath, "create");
8431
+ }
8432
+ const removedOriginal = await removeVikingResourceWithRetry(ov, config, candidate.sourceUri);
8433
+ if (!removedOriginal) {
8434
+ console.error(
8435
+ `Migrated copy stored, but original is still processing. Retry later: threadnote forget ${candidate.sourceUri}`
8436
+ );
8437
+ skippedCount += 1;
8438
+ }
8518
8439
  }
8440
+ migratedCount += 1;
8441
+ }
8442
+ } finally {
8443
+ if (!dryRun) {
8444
+ await (0, import_promises5.rm)(migrationPath, { force: true });
8519
8445
  }
8520
- }
8521
- if (options.dryRun !== true) {
8522
- await writeSeedState(statePath, state);
8523
8446
  }
8524
8447
  console.log(
8525
- `Seed complete: ${importedCount} candidate(s), ${unchangedCount} unchanged, ${skippedCount} skipped for safety.`
8448
+ [
8449
+ `Lifecycle migration summary: ${migratedCount} clear legacy handoff(s) ${dryRun ? "would be migrated" : "migrated"}`,
8450
+ `${existingCount} existing archived copy/copies reused`,
8451
+ `${skippedCount} legacy source(s) still processing`,
8452
+ `${candidates.length} clear legacy handoff candidate(s) found`,
8453
+ dryRun ? "Run with --apply to perform this migration." : void 0
8454
+ ].filter((part) => part !== void 0).join("; ")
8526
8455
  );
8527
8456
  }
8528
- function filterProjects(projects, only) {
8529
- if (!only || only.length === 0) {
8530
- return projects;
8457
+ async function runRecall(config, options) {
8458
+ if (options.dryRun !== true) {
8459
+ await syncSharedReposAndLog(config);
8531
8460
  }
8532
- const known = new Set(projects.map((project) => project.name));
8533
- const missing = only.filter((name) => !known.has(name));
8534
- if (missing.length > 0) {
8535
- const all = [...known].join(", ");
8536
- throw new Error(`Unknown project(s) in --only: ${missing.join(", ")}. Manifest projects: ${all}`);
8461
+ const ov = await openVikingCliForMode(options.dryRun === true);
8462
+ const inferredUri = options.uri ?? (options.inferScope === false ? void 0 : await inferRecallUri(config, options.query));
8463
+ const args = ["search", options.query];
8464
+ if (inferredUri) {
8465
+ args.push("--uri", inferredUri);
8466
+ console.log(`Recall scope: ${inferredUri}`);
8537
8467
  }
8538
- const want = new Set(only);
8539
- return projects.filter((project) => want.has(project.name));
8468
+ if (options.nodeLimit) {
8469
+ args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
8470
+ }
8471
+ await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
8472
+ await augmentRecallWithSeededResources(config, ov, options, inferredUri);
8473
+ await printExactMemoryMatches(config, ov, options.query, {
8474
+ dryRun: options.dryRun === true,
8475
+ includeArchived: options.includeArchived === true
8476
+ });
8540
8477
  }
8541
- async function statSeedFile(path) {
8542
- try {
8543
- const result = await (0, import_promises7.stat)(path);
8544
- return { mtimeMs: result.mtimeMs, size: result.size };
8545
- } catch (_err) {
8546
- return void 0;
8478
+ async function augmentRecallWithSeededResources(config, ov, options, inferredUri) {
8479
+ if (options.uri || options.inferScope === false) {
8480
+ return;
8481
+ }
8482
+ const project = await inferProjectFromQuery(config.manifestPath, options.query);
8483
+ if (!project) {
8484
+ return;
8485
+ }
8486
+ const projectResourceUri = trimTrailingSlash(project.uri);
8487
+ if (!projectResourceUri.startsWith("viking://") || projectResourceUri === inferredUri) {
8488
+ return;
8489
+ }
8490
+ const args = ["search", options.query, "--uri", projectResourceUri];
8491
+ if (options.nodeLimit) {
8492
+ args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
8547
8493
  }
8494
+ console.log(`
8495
+ Also searching seeded resources: ${projectResourceUri}`);
8496
+ await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
8548
8497
  }
8549
- async function readSeedState(path) {
8498
+ async function runRead(config, uri, options) {
8499
+ assertVikingUri(uri);
8500
+ if (options.dryRun !== true) {
8501
+ await syncSharedReposAndLog(config);
8502
+ }
8503
+ const ov = await openVikingCliForMode(options.dryRun === true);
8504
+ const result = await maybeRun(options.dryRun === true, ov, withIdentity(config, ["read", uri]));
8505
+ if (result && result.stdout.includes("[Directory overview is not ready]") && (uri.endsWith("/.overview.md") || uri.endsWith("/.abstract.md"))) {
8506
+ const parentUri2 = parentVikingUri(uri);
8507
+ console.log("\nThis is a generated summary placeholder. To read the underlying content, inspect leaf nodes:");
8508
+ console.log(` threadnote list ${parentUri2} --all --recursive`);
8509
+ }
8510
+ }
8511
+ async function syncSharedReposAndLog(config) {
8550
8512
  try {
8551
- const raw = await (0, import_promises7.readFile)(path, "utf8");
8552
- const parsed = JSON.parse(raw);
8553
- if (parsed.version !== 1 || !isJsonObject(parsed.files)) {
8554
- return { files: {}, version: 1 };
8513
+ const syncResult = await syncSharedReposBeforeAgentRead(config);
8514
+ if (syncResult.syncedTeams.length > 0) {
8515
+ console.error(`Auto-synced shared memories: ${syncResult.syncedTeams.join(", ")}`);
8555
8516
  }
8556
- const files = {};
8557
- for (const [uri, entry] of Object.entries(parsed.files)) {
8558
- if (isJsonObject(entry) && typeof entry.mtimeMs === "number" && typeof entry.size === "number") {
8559
- files[uri] = { mtimeMs: entry.mtimeMs, size: entry.size };
8560
- }
8517
+ for (const warning of syncResult.warnings) {
8518
+ console.error(`Auto-sync warning: ${warning}`);
8561
8519
  }
8562
- return { files, version: 1 };
8563
- } catch (_err) {
8564
- return { files: {}, version: 1 };
8520
+ } catch (err) {
8521
+ console.error(`Auto-sync warning: ${err instanceof Error ? err.message : String(err)}`);
8565
8522
  }
8566
8523
  }
8567
- async function writeSeedState(path, state) {
8568
- await ensureDirectory((0, import_node_path8.dirname)(path), false);
8569
- await (0, import_promises7.writeFile)(path, `${JSON.stringify(state, void 0, 2)}
8570
- `, { encoding: "utf8", mode: 384 });
8571
- }
8572
- async function runInitManifest(config, options) {
8573
- const manifestPath = expandPath(
8574
- options.path ?? process.env.THREADNOTE_MANIFEST ?? (0, import_node_path8.join)(config.agentContextHome, USER_MANIFEST_NAME)
8575
- );
8576
- const repoInputs = options.repo && options.repo.length > 0 ? options.repo : [getInvocationCwd()];
8577
- const existingManifest = options.replace === true || !await exists(manifestPath) ? void 0 : await readSeedManifest(manifestPath);
8578
- const existingProjects = existingManifest?.projects ?? [];
8579
- const projects = [...existingProjects];
8580
- const seen = /* @__PURE__ */ new Set();
8581
- for (const project of existingProjects) {
8582
- seen.add(await projectIdentity(project.path));
8524
+ async function runList(config, uri, options) {
8525
+ assertVikingUri(uri);
8526
+ const ov = await openVikingCliForMode(options.dryRun === true);
8527
+ const args = ["ls", uri];
8528
+ if (options.all === true) {
8529
+ args.push("--all");
8583
8530
  }
8584
- for (const repoInput of repoInputs) {
8585
- const repoRoot = await resolveRepoRoot(repoInput);
8586
- const identity = await projectIdentity(repoRoot);
8587
- if (seen.has(identity)) {
8588
- console.log(`Already in manifest: ${repoRoot}`);
8589
- continue;
8590
- }
8591
- seen.add(identity);
8592
- projects.push(projectManifestForRepo(repoRoot, projects));
8531
+ if (options.recursive === true) {
8532
+ args.push("--recursive");
8593
8533
  }
8594
- const outputManifest = {
8595
- version: 1,
8596
- projects: projects.map((project) => ({
8597
- name: project.name,
8598
- path: project.path,
8599
- uri: project.uri,
8600
- seed: [...project.seed]
8601
- }))
8602
- };
8603
- if (existingManifest?.futureMonorepo) {
8604
- const futureMonorepoKey = "future_monorepo";
8605
- const pathCandidatesKey = "path_candidates";
8606
- outputManifest[futureMonorepoKey] = {
8607
- [pathCandidatesKey]: [...existingManifest.futureMonorepo.pathCandidates],
8608
- uri: existingManifest.futureMonorepo.uri
8609
- };
8534
+ if (options.simple === true) {
8535
+ args.push("--simple");
8610
8536
  }
8611
- const output2 = jsYaml.dump(outputManifest, { lineWidth: 120, noRefs: true });
8612
- if (options.dryRun === true) {
8613
- console.log(`# Would write ${manifestPath}`);
8614
- console.log(output2.trimEnd());
8615
- return;
8537
+ if (options.nodeLimit) {
8538
+ args.push("--node-limit", String(parsePositiveInteger(options.nodeLimit, "node limit")));
8616
8539
  }
8617
- await ensureDirectory((0, import_node_path8.dirname)(manifestPath), false);
8618
- await (0, import_promises7.writeFile)(manifestPath, output2, { encoding: "utf8", mode: 384 });
8619
- await (0, import_promises7.chmod)(manifestPath, 384);
8620
- console.log(`Wrote manifest: ${manifestPath}`);
8621
- console.log("Seed with:");
8622
- console.log(" threadnote seed --dry-run");
8623
- console.log(" threadnote seed");
8540
+ await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
8624
8541
  }
8625
- async function runSeedSkills(config, options) {
8542
+ async function runHandoff(config, options) {
8543
+ const { bodyText, metadata } = await buildHandoff(options);
8544
+ await storeMemory(config, {
8545
+ bodyText,
8546
+ dryRun: options.dryRun === true,
8547
+ metadata,
8548
+ replaceUri: options.replace,
8549
+ title: "HANDOFF"
8550
+ });
8551
+ }
8552
+ async function runArchive(config, uri, options) {
8553
+ assertVikingUri(uri);
8626
8554
  const ov = await openVikingCliForMode(options.dryRun === true);
8627
- const skills = await collectSkillCandidates(config);
8628
- const nativeMode = options.native === true;
8629
- console.log(
8630
- nativeMode ? "Skill seed mode: native OpenViking skills. This requires a working VLM config." : "Skill seed mode: resource catalog. Use --native only after configuring a working VLM provider."
8631
- );
8632
- for (const skill of skills) {
8633
- console.log(`Skill ${skill.source}: ${skill.filePath}`);
8634
- const args = nativeMode ? ["add-skill", skill.filePath, "--wait"] : ["add-resource", skill.filePath, "--to", skillResourceUri(skill), "--wait"];
8635
- await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
8555
+ const readResult = await maybeRun(options.dryRun === true, ov, withIdentity(config, ["read", uri]));
8556
+ const original = readResult?.stdout.trim();
8557
+ if (options.dryRun === true) {
8558
+ const fallbackMetadata = {
8559
+ archivedFrom: uri,
8560
+ kind: options.kind ?? "handoff",
8561
+ project: normalizeOptionalMetadata(options.project),
8562
+ sourceAgentClient: "threadnote",
8563
+ status: "archived",
8564
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8565
+ topic: normalizeOptionalMetadata(options.topic)
8566
+ };
8567
+ await storeMemory(config, {
8568
+ bodyText: ["Archived original Threadnote memory.", "", "<original memory content would be read here>"].join("\n"),
8569
+ dryRun: true,
8570
+ metadata: fallbackMetadata,
8571
+ title: "MEMORY"
8572
+ });
8573
+ console.log(formatShellCommand(ov, withIdentity(config, ["rm", uri])));
8574
+ return;
8636
8575
  }
8637
- console.log(`Skill seed complete: ${skills.length} unique skill(s).`);
8638
- }
8639
- async function resolveRepoRoot(repoInput) {
8640
- const inputPath = expandPath(repoInput);
8641
- if (!await isDirectory(inputPath)) {
8642
- throw new Error(`Repo path is not a directory: ${inputPath}`);
8576
+ if (!original) {
8577
+ throw new Error(`Could not read ${uri} before archiving.`);
8643
8578
  }
8644
- return await gitValue(["rev-parse", "--show-toplevel"], inputPath) ?? inputPath;
8645
- }
8646
- async function projectIdentity(path) {
8647
- const expanded = expandPath(path);
8648
- try {
8649
- return await (0, import_promises7.realpath)(expanded);
8650
- } catch (_err) {
8651
- return expanded;
8579
+ const inferredMetadata = inferMemoryMetadata(original);
8580
+ const metadata = {
8581
+ archivedFrom: uri,
8582
+ kind: options.kind ?? inferredMetadata.kind ?? "handoff",
8583
+ project: normalizeOptionalMetadata(options.project) ?? inferredMetadata.project,
8584
+ sourceAgentClient: "threadnote",
8585
+ status: "archived",
8586
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8587
+ topic: normalizeOptionalMetadata(options.topic) ?? inferredMetadata.topic
8588
+ };
8589
+ await storeMemory(config, {
8590
+ bodyText: ["Archived original Threadnote memory.", "", original].join("\n"),
8591
+ dryRun: false,
8592
+ metadata,
8593
+ title: "MEMORY"
8594
+ });
8595
+ const removedOriginal = await removeVikingResourceWithRetry(ov, config, uri);
8596
+ if (removedOriginal) {
8597
+ console.log(`Archived original memory: ${uri}`);
8598
+ } else {
8599
+ console.error(`Archive stored, but original memory is still processing. Retry later: threadnote forget ${uri}`);
8652
8600
  }
8653
8601
  }
8654
- function projectManifestForRepo(repoRoot, existingProjects) {
8655
- const baseName = uriSegment((0, import_node_path8.basename)(repoRoot));
8656
- const usedNames = new Set(existingProjects.map((project) => project.name));
8657
- const usedUris = new Set(existingProjects.map((project) => project.uri));
8658
- let name = baseName;
8659
- let uri = `viking://resources/repos/${name}`;
8660
- if (usedNames.has(name) || usedUris.has(uri)) {
8661
- name = `${baseName}-${sha256(repoRoot).slice(0, 8)}`;
8662
- uri = `viking://resources/repos/${name}`;
8602
+ async function runForget(config, uri, options) {
8603
+ assertVikingUri(uri);
8604
+ const ov = await openVikingCliForMode(options.dryRun === true);
8605
+ if (options.dryRun === true) {
8606
+ await maybeRun(true, ov, withIdentity(config, ["rm", uri]));
8607
+ return;
8663
8608
  }
8664
- return {
8665
- name,
8666
- path: portablePath(repoRoot),
8667
- seed: DEFAULT_SEED_PATTERNS,
8668
- uri
8669
- };
8670
- }
8671
- async function collectSeedCandidates(project, projectRoot, ignorePatterns) {
8672
- const candidates = [];
8673
- const seen = /* @__PURE__ */ new Set();
8674
- for (const pattern of project.seed) {
8675
- const files = await resolveProjectPattern(projectRoot, pattern);
8676
- for (const filePath of files) {
8677
- const relativePath = toPosixPath((0, import_node_path8.relative)(projectRoot, filePath));
8678
- if (seen.has(relativePath) || matchesIgnore(relativePath, ignorePatterns)) {
8679
- continue;
8680
- }
8681
- seen.add(relativePath);
8682
- candidates.push({
8683
- destinationUri: `${trimTrailingSlash(project.uri)}/${relativePath}`,
8684
- filePath,
8685
- projectName: project.name,
8686
- relativePath
8687
- });
8688
- }
8609
+ const removed = await removeVikingResourceWithRetry(ov, config, uri);
8610
+ if (!removed) {
8611
+ throw new Error(`Resource is still being processed; retry later: threadnote forget ${uri}`);
8689
8612
  }
8690
- return candidates;
8691
8613
  }
8692
- async function resolveProjectPattern(projectRoot, pattern) {
8693
- const normalizedPattern = toPosixPath(pattern);
8694
- if (!hasGlob(normalizedPattern)) {
8695
- const filePath = (0, import_node_path8.join)(projectRoot, normalizedPattern);
8696
- return await isFile(filePath) ? [filePath] : [];
8697
- }
8698
- const globBase = getGlobBase(normalizedPattern);
8699
- const basePath = (0, import_node_path8.join)(projectRoot, globBase);
8700
- if (!await exists(basePath)) {
8701
- return [];
8614
+ async function runExportPack(config, options) {
8615
+ const ov = await openVikingCliForMode(options.dryRun === true);
8616
+ const defaultPath = (0, import_node_path5.join)(config.agentContextHome, `threadnote-${safeTimestamp()}.ovpack`);
8617
+ const outputPath = expandPath(options.path ?? defaultPath);
8618
+ await maybeRun(options.dryRun === true, ov, withIdentity(config, ["export", options.uri ?? "viking://", outputPath]));
8619
+ }
8620
+ async function runImportPack(config, options) {
8621
+ if (!options.path) {
8622
+ throw new Error("Provide --path for import-pack.");
8702
8623
  }
8703
- const regex = globToRegExp(normalizedPattern);
8704
- const files = await walkFiles(basePath);
8705
- return files.filter((filePath) => regex.test(toPosixPath((0, import_node_path8.relative)(projectRoot, filePath))));
8624
+ const ov = await openVikingCliForMode(options.dryRun === true);
8625
+ await maybeRun(
8626
+ options.dryRun === true,
8627
+ ov,
8628
+ withIdentity(config, ["import", expandPath(options.path), options.targetUri ?? "viking://"])
8629
+ );
8706
8630
  }
8707
- async function prepareSeedFile(config, candidate, dryRun) {
8708
- const content = await (0, import_promises7.readFile)(candidate.filePath, "utf8");
8709
- const redactedContent = shouldRedactPath(candidate.relativePath) ? redactContent(candidate.relativePath, content) : content;
8710
- const secretMatches = detectSecretMatches(redactedContent);
8711
- if (secretMatches.length > 0) {
8712
- console.log(
8713
- `SKIP ${candidate.projectName}/${candidate.relativePath}: possible secret (${secretMatches.slice(0, MAX_SECRET_MATCHES_TO_PRINT).join(", ")})`
8714
- );
8631
+ async function inferRecallUri(config, query) {
8632
+ if (!/\bskills?\b/.test(query.toLowerCase())) {
8715
8633
  return void 0;
8716
8634
  }
8717
- if (redactedContent === content) {
8718
- return candidate.filePath;
8719
- }
8720
- const redactedPath = (0, import_node_path8.join)(config.agentContextHome, "redacted", candidate.projectName, candidate.relativePath);
8721
- if (dryRun) {
8722
- console.log(`Would write redacted copy: ${redactedPath}`);
8723
- return redactedPath;
8724
- }
8725
- await ensureDirectory((0, import_node_path8.dirname)(redactedPath), false);
8726
- await (0, import_promises7.writeFile)(redactedPath, redactedContent, { encoding: "utf8", mode: 384 });
8727
- await (0, import_promises7.chmod)(redactedPath, 384);
8728
- return redactedPath;
8635
+ const project = await inferProjectFromQuery(config.manifestPath, query);
8636
+ return project ? `viking://resources/agent-skills/repo-local-${uriSegment(project.name)}` : "viking://resources/agent-skills";
8729
8637
  }
8730
- async function collectSkillCandidates(config) {
8731
- const sources = [
8732
- { pattern: "~/.codex/skills/**/SKILL.md", source: "codex-global" },
8733
- { pattern: "~/.codex/plugins/cache/**/skills/**/SKILL.md", source: "codex-plugin-cache" },
8734
- { pattern: "~/.claude/skills/**/SKILL.md", source: "claude-global" }
8735
- ];
8736
- try {
8737
- const manifest = await readSeedManifest(config.manifestPath);
8738
- for (const project of manifest.projects) {
8739
- sources.push({
8740
- pattern: `${project.path}/.claude/skills/**/SKILL.md`,
8741
- source: `repo-local:${project.name}`
8742
- });
8743
- }
8744
- } catch (err) {
8745
- console.log(`WARN cannot read manifest for repo-local skill discovery: ${errorMessage(err)}`);
8638
+ async function printExactMemoryMatches(config, ov, query, options) {
8639
+ const terms = exactRecallTerms(query);
8640
+ if (terms.length === 0) {
8641
+ return;
8746
8642
  }
8747
- const seenHashes = /* @__PURE__ */ new Set();
8748
- const skills = [];
8749
- for (const source of sources) {
8750
- const files = await resolveAbsolutePattern(expandPath(source.pattern));
8751
- for (const filePath of files) {
8752
- const content = await (0, import_promises7.readFile)(filePath, "utf8");
8753
- const matches = detectSecretMatches(content);
8754
- if (matches.length > 0) {
8755
- console.log(`SKIP skill with possible secret: ${filePath}`);
8643
+ const scopes = exactMemoryScopes(config, options.includeArchived);
8644
+ const outputs = [];
8645
+ for (const term of terms) {
8646
+ for (const scope of scopes) {
8647
+ const args = withIdentity(config, ["grep", term, "--uri", scope, "--node-limit", "5"]);
8648
+ if (options.dryRun) {
8649
+ outputs.push(formatShellCommand(ov, args));
8756
8650
  continue;
8757
8651
  }
8758
- const hash = sha256(content);
8759
- if (seenHashes.has(hash)) {
8760
- continue;
8652
+ const result = await runCommand(ov, args, { allowFailure: true });
8653
+ const output2 = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
8654
+ if (result.exitCode === 0 && grepOutputHasMatches(output2)) {
8655
+ outputs.push(output2);
8761
8656
  }
8762
- seenHashes.add(hash);
8763
- skills.push({ filePath, hash, source: source.source });
8764
8657
  }
8765
8658
  }
8766
- return skills;
8659
+ if (outputs.length === 0) {
8660
+ return;
8661
+ }
8662
+ console.log("\nExact durable memory matches:");
8663
+ console.log(outputs.join("\n\n"));
8767
8664
  }
8768
- async function resolveAbsolutePattern(pattern) {
8769
- const normalizedPattern = toPosixPath(pattern);
8770
- if (!hasGlob(normalizedPattern)) {
8771
- return await isFile(normalizedPattern) ? [normalizedPattern] : [];
8665
+ async function storeMemory(config, options) {
8666
+ if (options.replaceUri) {
8667
+ assertVikingUri(options.replaceUri);
8772
8668
  }
8773
- const globBase = getGlobBase(normalizedPattern);
8774
- const basePath = globBase.startsWith("/") ? globBase : `/${globBase}`;
8775
- if (!await exists(basePath)) {
8776
- return [];
8669
+ const ov = await openVikingCliForMode(options.dryRun);
8670
+ const memoryPath = (0, import_node_path5.join)(config.agentContextHome, "last-memory.txt");
8671
+ const candidateMetadata = { ...options.metadata, supersedes: options.replaceUri };
8672
+ const candidateMemory = formatMemoryDocument(options.title, candidateMetadata, options.bodyText);
8673
+ const memoryUri = memoryUriFor(config, candidateMemory, candidateMetadata);
8674
+ const isInPlaceUpdate = options.replaceUri !== void 0 && options.replaceUri === memoryUri;
8675
+ const finalMetadata = isInPlaceUpdate ? { ...options.metadata, supersedes: void 0 } : candidateMetadata;
8676
+ const memory = isInPlaceUpdate ? formatMemoryDocument(options.title, finalMetadata, options.bodyText) : candidateMemory;
8677
+ const writeMode = await memoryWriteMode(ov, config, memoryUri, finalMetadata);
8678
+ if (options.dryRun) {
8679
+ console.log(memory);
8680
+ console.log("\nWould run:");
8681
+ console.log(
8682
+ formatShellCommand(
8683
+ ov,
8684
+ withIdentity(config, [
8685
+ "write",
8686
+ memoryUri,
8687
+ "--from-file",
8688
+ memoryPath,
8689
+ "--mode",
8690
+ writeMode,
8691
+ "--wait",
8692
+ "--timeout",
8693
+ "120"
8694
+ ])
8695
+ )
8696
+ );
8697
+ if (options.replaceUri && !isInPlaceUpdate) {
8698
+ console.log(formatShellCommand(ov, withIdentity(config, ["rm", options.replaceUri])));
8699
+ }
8700
+ return;
8701
+ }
8702
+ await (0, import_promises5.writeFile)(memoryPath, memory, { encoding: "utf8", mode: 384 });
8703
+ await (0, import_promises5.chmod)(memoryPath, 384);
8704
+ await ensureMemoryDirectory(ov, config, memoryDirectoryUri(config, finalMetadata));
8705
+ await writeDurableMemoryFile(ov, config, memoryUri, memoryPath, writeMode);
8706
+ console.log(`Stored memory: ${memoryUri}`);
8707
+ if (options.replaceUri && !isInPlaceUpdate) {
8708
+ const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config, options.replaceUri);
8709
+ if (removedReplacedMemory) {
8710
+ console.log(`Forgot replaced memory: ${options.replaceUri}`);
8711
+ } else {
8712
+ console.error(
8713
+ `Replacement stored, but the superseded memory is still processing. Retry later: threadnote forget ${options.replaceUri}`
8714
+ );
8715
+ }
8716
+ } else if (isInPlaceUpdate) {
8717
+ console.log(`Updated existing memory in place: ${memoryUri}`);
8777
8718
  }
8778
- const regex = globToRegExp(normalizedPattern);
8779
- const files = await walkFiles(basePath);
8780
- return files.filter((filePath) => regex.test(toPosixPath(filePath)));
8781
8719
  }
8782
- function skillResourceUri(skill) {
8783
- return `viking://resources/agent-skills/${uriSegment(skill.source)}/${uriSegment((0, import_node_path8.basename)((0, import_node_path8.dirname)(skill.filePath)))}-${skill.hash.slice(0, 12)}.md`;
8720
+ async function writeDurableMemoryFile(ov, config, memoryUri, memoryPath, writeMode) {
8721
+ const args = withIdentity(config, [
8722
+ "write",
8723
+ memoryUri,
8724
+ "--from-file",
8725
+ memoryPath,
8726
+ "--mode",
8727
+ writeMode,
8728
+ "--wait",
8729
+ "--timeout",
8730
+ "120"
8731
+ ]);
8732
+ for (let attempt = 0; attempt < 4; attempt += 1) {
8733
+ console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
8734
+ const result = await runCommand(ov, args, { allowFailure: true });
8735
+ if (result.exitCode === 0) {
8736
+ if (result.stdout.trim()) {
8737
+ console.log(result.stdout.trim());
8738
+ }
8739
+ if (result.stderr.trim()) {
8740
+ console.error(result.stderr.trim());
8741
+ }
8742
+ return;
8743
+ }
8744
+ if (await vikingResourceExists2(ov, config, memoryUri)) {
8745
+ console.log("OpenViking accepted the memory but returned before the wait completed; waiting for indexing.");
8746
+ await waitForOpenVikingQueue(ov, config);
8747
+ return;
8748
+ }
8749
+ if (!isResourceBusy(result.stderr, result.stdout) || attempt === 3) {
8750
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
8751
+ }
8752
+ await sleep(1e3 * (attempt + 1));
8753
+ }
8784
8754
  }
8785
- async function loadIgnorePatterns() {
8786
- const raw = await (0, import_promises7.readFile)((0, import_node_path8.join)(toolRoot(), ".threadnoteignore"), "utf8");
8787
- return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
8755
+ async function waitForOpenVikingQueue(ov, config) {
8756
+ const result = await runCommand(ov, withIdentity(config, ["wait", "--timeout", "120"]), { allowFailure: true });
8757
+ if (result.stdout.trim()) {
8758
+ console.log(result.stdout.trim());
8759
+ }
8760
+ if (result.stderr.trim()) {
8761
+ console.error(result.stderr.trim());
8762
+ }
8788
8763
  }
8789
- function matchesIgnore(relativePath, patterns) {
8790
- const path = toPosixPath(relativePath);
8791
- for (const pattern of patterns) {
8792
- const normalizedPattern = toPosixPath(pattern);
8793
- if (normalizedPattern.endsWith("/")) {
8794
- const directory = normalizedPattern.slice(0, -1);
8795
- if (path === directory || path.includes(`/${directory}/`) || path.startsWith(`${directory}/`)) {
8796
- return true;
8764
+ async function removeVikingResourceWithRetry(ov, config, uri) {
8765
+ const args = withIdentity(config, ["rm", uri]);
8766
+ for (let attempt = 0; attempt < 4; attempt += 1) {
8767
+ console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
8768
+ const result = await runCommand(ov, args, { allowFailure: true });
8769
+ if (result.exitCode === 0) {
8770
+ if (result.stdout.trim()) {
8771
+ console.log(result.stdout.trim());
8772
+ }
8773
+ if (result.stderr.trim()) {
8774
+ console.error(result.stderr.trim());
8797
8775
  }
8798
- continue;
8799
- }
8800
- if (globToRegExp(normalizedPattern).test(path) || globToRegExp(`**/${normalizedPattern}`).test(path)) {
8801
8776
  return true;
8802
8777
  }
8778
+ if (isResourceBusy(result.stderr, result.stdout) && attempt === 3) {
8779
+ return false;
8780
+ }
8781
+ if (!isResourceBusy(result.stderr, result.stdout)) {
8782
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
8783
+ }
8784
+ await sleep(1e3 * (attempt + 1));
8803
8785
  }
8804
8786
  return false;
8805
8787
  }
8806
- function shouldRedactPath(relativePath) {
8807
- const path = toPosixPath(relativePath);
8808
- return path.endsWith(".mcp.json") || path.endsWith("config.toml") || path.includes("/settings") || path.includes("/settings.local");
8788
+ async function vikingResourceExists2(ov, config, uri) {
8789
+ const stat4 = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
8790
+ return stat4.exitCode === 0;
8809
8791
  }
8810
- function redactContent(relativePath, content) {
8811
- if (relativePath.endsWith(".json")) {
8812
- try {
8813
- const parsed = JSON.parse(content);
8814
- return `${JSON.stringify(redactJsonValue(parsed), null, 2)}
8815
- `;
8816
- } catch (_err) {
8817
- return redactText(content);
8792
+ async function ensureDurableMemoryDirectory(ov, config) {
8793
+ await ensureMemoryDirectory(ov, config, durableMemoryDirectoryUri(config));
8794
+ }
8795
+ async function ensureMemoryDirectory(ov, config, directoryUri) {
8796
+ for (const uri of vikingDirectoryChain(directoryUri)) {
8797
+ const statResult = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
8798
+ if (statResult.exitCode === 0) {
8799
+ continue;
8818
8800
  }
8801
+ await maybeRun(
8802
+ false,
8803
+ ov,
8804
+ withIdentity(config, ["mkdir", uri, "--description", "Threadnote lifecycle-aware local memories."])
8805
+ );
8819
8806
  }
8820
- return redactText(content);
8821
8807
  }
8822
- function redactJsonValue(value) {
8823
- if (Array.isArray(value)) {
8824
- return value.map((item) => redactJsonValue(item));
8825
- }
8826
- if (!isJsonObject(value)) {
8827
- return value;
8808
+ function durableMemoryDirectoryUri(config) {
8809
+ return `viking://user/${uriSegment(config.user)}/memories/events`;
8810
+ }
8811
+ function migratedDurableMemoryUri(config, hash) {
8812
+ return `${durableMemoryDirectoryUri(config)}/threadnote-migrated-${hash.slice(0, 16)}.md`;
8813
+ }
8814
+ async function hasLegacyLifecycleHandoffCandidates(config) {
8815
+ return (await legacyLifecycleHandoffCandidates(config, 1)).length > 0;
8816
+ }
8817
+ async function legacyLifecycleHandoffCandidates(config, limit) {
8818
+ const eventsRoot = (0, import_node_path5.join)(localUserMemoriesRoot(config), "events");
8819
+ let entries;
8820
+ try {
8821
+ entries = await (0, import_promises5.readdir)(eventsRoot, { withFileTypes: true });
8822
+ } catch (_err) {
8823
+ return [];
8828
8824
  }
8829
- const output2 = {};
8830
- for (const [key, nestedValue] of Object.entries(value)) {
8831
- output2[key] = isSensitiveKey(key) ? "[REDACTED]" : redactJsonValue(nestedValue);
8825
+ const candidates = [];
8826
+ for (const entry of entries) {
8827
+ if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md")) {
8828
+ continue;
8829
+ }
8830
+ const sourcePath = (0, import_node_path5.join)(eventsRoot, entry.name);
8831
+ const original = await readTextIfExists(sourcePath);
8832
+ if (!original || !isClearLegacyHandoffMemory(original) || sensitiveMemoryReason(original)) {
8833
+ continue;
8834
+ }
8835
+ const sourceUri = `${durableMemoryDirectoryUri(config)}/${entry.name}`;
8836
+ candidates.push({
8837
+ metadata: {
8838
+ archivedFrom: sourceUri,
8839
+ kind: "handoff",
8840
+ project: inferLegacyProject(original),
8841
+ sourceAgentClient: "threadnote",
8842
+ status: "archived",
8843
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
8844
+ },
8845
+ original,
8846
+ sourceUri
8847
+ });
8848
+ if (limit !== void 0 && candidates.length >= limit) {
8849
+ break;
8850
+ }
8832
8851
  }
8833
- return output2;
8852
+ return candidates;
8834
8853
  }
8835
- function isSensitiveKey(key) {
8836
- return /token|secret|password|credential|authorization|api[_-]?key|client[_-]?secret|bearer/i.test(key);
8854
+ function lifecycleMigrationUri(config, metadata, hash) {
8855
+ return `${memoryDirectoryUri(config, metadata)}/legacy-${hash.slice(0, 16)}.md`;
8837
8856
  }
8838
- function detectSecretMatches(content) {
8839
- const detectors = [
8840
- { label: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
8841
- { label: "openai-key", regex: /sk-[A-Za-z0-9_-]{24,}/ },
8842
- { label: "github-token", regex: /gh[pousr]_[A-Za-z0-9_]{24,}/ },
8843
- { label: "slack-token", regex: /xox[abprs]-[A-Za-z0-9-]{24,}/ },
8844
- { label: "bearer-token", regex: /Bearer\s+[A-Za-z0-9._~+/=-]{24,}/i },
8845
- { label: "aws-access-key", regex: /AKIA[0-9A-Z]{16}/ }
8857
+ function exactMemoryScopes(config, includeArchived) {
8858
+ const userBase = `viking://user/${uriSegment(config.user)}/memories`;
8859
+ const scopes = [
8860
+ `${userBase}/preferences`,
8861
+ `${userBase}/durable/projects`,
8862
+ `${userBase}/handoffs/active`,
8863
+ `${userBase}/incidents/active`,
8864
+ `${userBase}/events`,
8865
+ `${userBase}/shared`,
8866
+ `viking://agent/${uriSegment(config.agentId)}/memories`,
8867
+ // Seeded project resources (READMEs, AGENTS.md, SKILL.md, docs/**) live
8868
+ // under viking://resources/repos/<project>. Include them so an exact-term
8869
+ // grep in a recall surfaces matches in seeded guidance, not only memories.
8870
+ "viking://resources/repos"
8846
8871
  ];
8847
- const matches = [];
8848
- for (const detector of detectors) {
8849
- if (detector.regex.test(content)) {
8850
- matches.push(detector.label);
8851
- }
8872
+ return includeArchived ? [...scopes, `${userBase}/durable/archived`, `${userBase}/handoffs/archived`, `${userBase}/incidents/archived`] : scopes;
8873
+ }
8874
+ function memoryUriFor(config, memory, metadata) {
8875
+ const filename = shouldUseStableMemoryUri(metadata) ? `${uriSegment(metadata.topic ?? "current")}.md` : `threadnote-${safeTimestamp()}-${sha256(memory).slice(0, 12)}.md`;
8876
+ return `${memoryDirectoryUri(config, metadata)}/${filename}`;
8877
+ }
8878
+ function memoryDirectoryUri(config, metadata) {
8879
+ const baseUri = `viking://user/${uriSegment(config.user)}/memories`;
8880
+ const projectSegment = uriSegment(metadata.project ?? "general");
8881
+ switch (metadata.kind) {
8882
+ case "preference":
8883
+ return metadata.status === "active" ? `${baseUri}/preferences` : `${baseUri}/preferences/${uriSegment(metadata.status)}`;
8884
+ case "handoff":
8885
+ return `${baseUri}/handoffs/${uriSegment(metadata.status)}/${projectSegment}`;
8886
+ case "incident":
8887
+ return `${baseUri}/incidents/${uriSegment(metadata.status)}/${projectSegment}`;
8888
+ case "smoke":
8889
+ return `${baseUri}/smoke/${uriSegment(metadata.status)}`;
8890
+ case "durable":
8891
+ return metadata.status === "active" ? `${baseUri}/durable/projects/${projectSegment}` : `${baseUri}/durable/${uriSegment(metadata.status)}/${projectSegment}`;
8852
8892
  }
8853
- return matches;
8854
8893
  }
8855
-
8856
- // src/share.ts
8857
- var import_promises8 = require("node:fs/promises");
8858
- var import_node_os4 = require("node:os");
8859
- var import_node_path9 = require("node:path");
8860
- var TEAMS_FILE_VERSION = 1;
8861
- var SHARED_SEGMENT = "shared";
8862
- var SHAREABLE_MEMORY_KIND_DIRS = ["durable"];
8863
- var DEFAULT_GIT_REMOTE_NAME = "origin";
8864
- var SCRUBBER_PATTERNS = [
8865
- // Credentials: never redactable. Blocking is the only safe response —
8866
- // automated redaction risks false negatives that leave material in git.
8867
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
8868
- { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
8869
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
8870
- { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
8871
- { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
8872
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
8873
- // Matches bare JWTs (three base64url segments). May surface a JWE token in
8874
- // legitimate docs; if that becomes noisy we can switch to warn-only.
8875
- { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
8876
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
8877
- // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
8878
- // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
8879
- { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
8880
- // Soft leaks: block by default (so the agent sees them and decides), but
8881
- // allow opt-in redaction so curated memories with incidental matches can
8882
- // ship without a manual rewrite. Local home paths are the recurring
8883
- // real-world leak; the regexes greedily consume the whole path segment
8884
- // (including subdirectories) up to whitespace or common closing punctuation
8885
- // so redaction collapses an entire path to a single placeholder rather than
8886
- // leaving the subpath visible.
8887
- { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
8888
- { name: "linux home path", placeholder: "<local-path>", regex: /\b\/home\/[^\s)>"'`,]+/ }
8889
- ];
8890
- function applyScrubber(content, { redact }) {
8891
- let cleaned = content;
8892
- const redactions = [];
8893
- for (const pattern of SCRUBBER_PATTERNS) {
8894
- if (!pattern.regex.test(cleaned)) {
8895
- continue;
8896
- }
8897
- if (!pattern.placeholder || !redact) {
8898
- return { blocker: pattern.name, cleaned: content, redactions: [] };
8899
- }
8900
- const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
8901
- const globalRegex = new RegExp(pattern.regex.source, flags);
8902
- const matches = cleaned.match(globalRegex) ?? [];
8903
- cleaned = cleaned.replace(globalRegex, pattern.placeholder);
8904
- redactions.push({ count: matches.length, name: pattern.name });
8905
- }
8906
- return { cleaned, redactions };
8894
+ function shouldUseStableMemoryUri(metadata) {
8895
+ return metadata.status === "active" && metadata.topic !== void 0 && metadata.kind !== "smoke";
8907
8896
  }
8908
- async function runShareInit(config, remoteUrl, options) {
8909
- if (!remoteUrl.trim()) {
8910
- throw new Error("Provide a git remote URL for the shared memories repo.");
8897
+ async function memoryWriteMode(ov, config, memoryUri, metadata) {
8898
+ if (!shouldUseStableMemoryUri(metadata)) {
8899
+ return "create";
8911
8900
  }
8912
- const dryRun = options.dryRun === true;
8913
- const teamName = normalizeTeamName(options.team);
8914
- const teamsFile = await readTeamsFile(config);
8915
- if (teamsFile.teams[teamName]) {
8916
- throw new Error(
8917
- `Team "${teamName}" is already configured (remote ${teamsFile.teams[teamName].remote}). Remove it first with: threadnote share remove --team ${teamName}`
8918
- );
8901
+ return await vikingResourceExists2(ov, config, memoryUri) ? "replace" : "create";
8902
+ }
8903
+ function vikingDirectoryChain(directoryUri) {
8904
+ const prefix = "viking://";
8905
+ if (!directoryUri.startsWith(prefix)) {
8906
+ return [directoryUri];
8919
8907
  }
8920
- const worktree = teamWorktreePath(config, teamName);
8921
- const gitdir = teamGitdirPath(config, teamName);
8922
- await assertWorktreeUsable(worktree);
8923
- if (await exists(gitdir)) {
8924
- throw new Error(`Gitdir already exists at ${gitdir}; remove it or pick a different team name.`);
8908
+ const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
8909
+ const startIndex = parts[0] === "user" && parts.length > 2 ? 3 : 1;
8910
+ const chain = [];
8911
+ for (let index = startIndex; index <= parts.length; index += 1) {
8912
+ chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
8925
8913
  }
8926
- await ensureDirectory((0, import_node_path9.dirname)(worktree), dryRun);
8927
- await ensureDirectory((0, import_node_path9.dirname)(gitdir), dryRun);
8928
- const git = await requiredExecutable("git");
8929
- await maybeRun(dryRun, git, ["clone", `--separate-git-dir=${gitdir}`, "--", remoteUrl, worktree]);
8930
- const newConfig = {
8931
- addedAt: (/* @__PURE__ */ new Date()).toISOString(),
8932
- gitdir,
8933
- name: teamName,
8934
- remote: remoteUrl,
8935
- worktree
8936
- };
8937
- const updatedTeams = {
8938
- defaultTeam: shouldSetDefault(options, teamsFile) ? teamName : teamsFile.defaultTeam ?? teamName,
8939
- teams: { ...teamsFile.teams, [teamName]: newConfig },
8940
- version: TEAMS_FILE_VERSION
8914
+ return chain;
8915
+ }
8916
+ function formatMemoryDocument(title, metadata, body) {
8917
+ const header = [
8918
+ title,
8919
+ `kind: ${metadata.kind}`,
8920
+ `status: ${metadata.status}`,
8921
+ metadata.project ? `project: ${metadata.project}` : void 0,
8922
+ metadata.topic ? `topic: ${metadata.topic}` : void 0,
8923
+ `source_agent_client: ${metadata.sourceAgentClient}`,
8924
+ `timestamp: ${metadata.timestamp}`,
8925
+ metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
8926
+ metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
8927
+ ].filter((line) => line !== void 0);
8928
+ return [...header, "", body.trim()].join("\n");
8929
+ }
8930
+ function inferMemoryMetadata(memory) {
8931
+ const header = memory.slice(0, Math.max(0, memory.indexOf("\n\n")) || memory.length);
8932
+ const firstLine2 = header.split("\n")[0]?.trim();
8933
+ const kind = parseOptionalMemoryKind(parseHeaderValue(header, "kind")) ?? (firstLine2 === "HANDOFF" ? "handoff" : void 0);
8934
+ const status = parseOptionalMemoryStatus(parseHeaderValue(header, "status"));
8935
+ const project = normalizeOptionalMetadata(parseHeaderValue(header, "project")) ?? normalizeOptionalMetadata(parseHeaderValue(header, "repo"));
8936
+ const topic = normalizeOptionalMetadata(parseHeaderValue(header, "topic")) ?? normalizeOptionalMetadata(parseHeaderValue(header, "task"));
8937
+ return {
8938
+ kind,
8939
+ project,
8940
+ sourceAgentClient: parseHeaderValue(header, "source_agent_client") ?? void 0,
8941
+ status,
8942
+ topic
8941
8943
  };
8942
- if (dryRun) {
8943
- console.log(`Would write teams file: ${teamsFilePath(config)}`);
8944
- console.log(`Would set ${teamName} as default? ${updatedTeams.defaultTeam === teamName}`);
8945
- } else {
8946
- await writeTeamsFile(config, updatedTeams);
8947
- console.log(`Configured shared team "${teamName}" -> ${portablePath(worktree)}`);
8944
+ }
8945
+ function parseHeaderValue(header, key) {
8946
+ const prefix = `${key}:`;
8947
+ return header.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim();
8948
+ }
8949
+ function isClearLegacyHandoffMemory(memory) {
8950
+ if (/^kind:\s*/m.test(memory) || /^status:\s*/m.test(memory)) {
8951
+ return false;
8948
8952
  }
8949
- if (!dryRun) {
8950
- await ensureSharedGitignore(worktree, git, options.push !== false);
8951
- const ingested = await ingestWorktreeFiles(config, newConfig, "create");
8952
- console.log(`Ingested ${ingested} shared memory file(s) into OpenViking.`);
8953
+ const trimmed = memory.trim();
8954
+ if (trimmed.startsWith("HANDOFF\n")) {
8955
+ return true;
8953
8956
  }
8957
+ if (!trimmed.startsWith("MEMORY\n")) {
8958
+ return false;
8959
+ }
8960
+ return /^(?:#+\s*)?(?:final\s+)?handoff(?:\s+update)?\b/i.test(memoryBody(trimmed));
8954
8961
  }
8955
- var SHARED_GITIGNORE_PATTERNS = ["**/.abstract.md", "**/.overview.md"];
8956
- var SHARED_GITIGNORE_HEADER = "# Threadnote: ignore OpenViking-generated directory summaries.";
8957
- async function ensureSharedGitignore(worktree, git, push) {
8958
- const gitignorePath = (0, import_node_path9.join)(worktree, ".gitignore");
8959
- const existing = await readFileIfExists(gitignorePath) ?? "";
8960
- const lines = existing.split("\n").map((line) => line.trim());
8961
- const missingPatterns = SHARED_GITIGNORE_PATTERNS.filter((pattern) => !lines.includes(pattern));
8962
- if (missingPatterns.length === 0) {
8963
- return;
8962
+ function memoryBody(memory) {
8963
+ const separatorIndex = memory.indexOf("\n\n");
8964
+ return separatorIndex === -1 ? "" : memory.slice(separatorIndex + 2).trim();
8965
+ }
8966
+ function inferLegacyProject(memory) {
8967
+ const explicit = parseHeaderValue(memory, "project") ?? parseHeaderValue(memory, "repo") ?? parseHeaderValue(memory, "repo_path") ?? /\brepo(?:_path)?\s+([~/A-Za-z0-9_.:/-]+)/.exec(memory)?.[1];
8968
+ if (!explicit) {
8969
+ return "general";
8964
8970
  }
8965
- const hasHeader = lines.includes(SHARED_GITIGNORE_HEADER);
8966
- const segments = [];
8967
- if (existing.length > 0 && !existing.endsWith("\n")) {
8968
- segments.push("\n");
8971
+ const trimmed = explicit.trim().replace(/[`.,;]+$/g, "");
8972
+ return trimmed.includes("/") ? (0, import_node_path5.basename)(trimmed) : trimmed;
8973
+ }
8974
+ function parseOptionalMemoryKind(value) {
8975
+ if (!value) {
8976
+ return void 0;
8969
8977
  }
8970
- if (existing.length > 0) {
8971
- segments.push("\n");
8978
+ try {
8979
+ return parseMemoryKind(value);
8980
+ } catch (_err) {
8981
+ return void 0;
8972
8982
  }
8973
- if (!hasHeader) {
8974
- segments.push(SHARED_GITIGNORE_HEADER, "\n");
8983
+ }
8984
+ function parseOptionalMemoryStatus(value) {
8985
+ if (!value) {
8986
+ return void 0;
8975
8987
  }
8976
- segments.push(missingPatterns.join("\n"), "\n");
8977
- await (0, import_promises8.writeFile)(gitignorePath, `${existing}${segments.join("")}`, { encoding: "utf8" });
8978
- console.log(`Added ${missingPatterns.join(", ")} to ${portablePath(gitignorePath)}`);
8979
- await maybeRun(false, git, ["-C", worktree, "add", ".gitignore"]);
8980
- const commitResult = await runCommand(
8981
- git,
8982
- ["-C", worktree, "commit", "-m", "share: ignore OpenViking directory summaries"],
8983
- { allowFailure: true }
8984
- );
8985
- if (commitResult.exitCode !== 0) {
8986
- const detail = commitResult.stderr.trim() || commitResult.stdout.trim();
8987
- if (!/nothing to commit|no changes added/i.test(detail)) {
8988
- console.warn(
8989
- `.gitignore housekeeping commit was rejected (${detail || "unknown"}); it will be retried on the next share sync.`
8990
- );
8991
- return;
8988
+ try {
8989
+ return parseMemoryStatus(value);
8990
+ } catch (_err) {
8991
+ return void 0;
8992
+ }
8993
+ }
8994
+ function normalizeOptionalMetadata(value) {
8995
+ const trimmed = value?.trim();
8996
+ return trimmed ? trimmed : void 0;
8997
+ }
8998
+ async function legacySourceAccounts(config, options) {
8999
+ const explicitAccounts = options.sourceAccount?.filter((account) => account.trim().length > 0) ?? [];
9000
+ if (explicitAccounts.length > 0) {
9001
+ return uniqueStrings(explicitAccounts);
9002
+ }
9003
+ if (options.allAccounts === true) {
9004
+ const accounts = await childDirectoryNames(localVikingDataRoot(config));
9005
+ return accounts.filter((account) => !account.startsWith("_"));
9006
+ }
9007
+ return [config.account];
9008
+ }
9009
+ async function legacyMemoryCandidates(config, sourceAccounts) {
9010
+ const candidates = [];
9011
+ for (const sourceAccount of sourceAccounts) {
9012
+ const sessionRoot = (0, import_node_path5.join)(localVikingDataRoot(config), sourceAccount, "session");
9013
+ for (const sourceSession of await childDirectoryNames(sessionRoot)) {
9014
+ const historyRoot = (0, import_node_path5.join)(sessionRoot, sourceSession, "history");
9015
+ for (const sourceArchive of await childDirectoryNames(historyRoot)) {
9016
+ if (!sourceArchive.startsWith("archive_")) {
9017
+ continue;
9018
+ }
9019
+ const sourcePath = (0, import_node_path5.join)(historyRoot, sourceArchive, "messages.jsonl");
9020
+ for (const text of await legacyMemoryTexts(sourcePath)) {
9021
+ candidates.push({
9022
+ comparableHash: sha256(comparableMemoryText(text)),
9023
+ hash: sha256(text),
9024
+ sourceAccount,
9025
+ sourceArchive,
9026
+ sourceSession,
9027
+ text
9028
+ });
9029
+ }
9030
+ }
8992
9031
  }
8993
9032
  }
8994
- if (push) {
8995
- await maybeRun(false, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
9033
+ return candidates.sort((left, right) => legacySourceLabel(left).localeCompare(legacySourceLabel(right)));
9034
+ }
9035
+ async function legacyMemoryTexts(sourcePath) {
9036
+ const raw = await readTextIfExists(sourcePath);
9037
+ if (!raw) {
9038
+ return [];
9039
+ }
9040
+ const memories = [];
9041
+ for (const line of raw.split("\n")) {
9042
+ const trimmedLine = line.trim();
9043
+ if (!trimmedLine) {
9044
+ continue;
9045
+ }
9046
+ try {
9047
+ const parsed = JSON.parse(trimmedLine);
9048
+ const text = legacyMessageText(parsed)?.trim();
9049
+ if (text && isLegacyThreadnoteMemory(text)) {
9050
+ memories.push(text);
9051
+ }
9052
+ } catch (_err) {
9053
+ continue;
9054
+ }
8996
9055
  }
9056
+ return memories;
8997
9057
  }
8998
- async function runShareStatus(config, options) {
8999
- const team = await resolveTeam(config, options.team);
9000
- const git = await requiredExecutable("git");
9001
- console.log(`Team: ${team.name}`);
9002
- console.log(`Remote: ${team.config.remote}`);
9003
- console.log(`Worktree: ${portablePath(team.config.worktree)}`);
9004
- console.log(`Gitdir: ${portablePath(team.config.gitdir)}`);
9005
- await maybeRun(options.dryRun === true, git, ["-C", team.config.worktree, "status", "--short", "--branch"]);
9006
- await maybeRun(options.dryRun === true, git, ["-C", team.config.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
9007
- allowFailure: true
9008
- });
9009
- const ahead = await gitOutput(team.config.worktree, ["rev-list", "--count", "@{u}..HEAD"], options.dryRun === true);
9010
- const behind = await gitOutput(team.config.worktree, ["rev-list", "--count", "HEAD..@{u}"], options.dryRun === true);
9011
- if (ahead !== void 0) {
9012
- console.log(`Ahead of upstream: ${ahead}`);
9058
+ function legacyMessageText(value) {
9059
+ if (!isJsonObject(value)) {
9060
+ return void 0;
9013
9061
  }
9014
- if (behind !== void 0) {
9015
- console.log(`Behind upstream: ${behind}`);
9062
+ if (typeof value.content === "string") {
9063
+ return value.content;
9016
9064
  }
9065
+ if (!Array.isArray(value.parts)) {
9066
+ return void 0;
9067
+ }
9068
+ const parts = value.parts.map((part) => isJsonObject(part) && part.type === "text" && typeof part.text === "string" ? part.text : void 0).filter((text) => text !== void 0);
9069
+ return parts.length > 0 ? parts.join("\n") : void 0;
9017
9070
  }
9018
- async function runShareSync(config, options) {
9019
- const team = await resolveTeam(config, options.team);
9020
- const dryRun = options.dryRun === true;
9021
- const git = await requiredExecutable("git");
9022
- const worktree = team.config.worktree;
9023
- if (!dryRun) {
9024
- await ensureSharedGitignore(worktree, git, false);
9071
+ function isLegacyThreadnoteMemory(text) {
9072
+ return text.startsWith("MEMORY\n") || text.startsWith("HANDOFF\n");
9073
+ }
9074
+ async function existingDurableMemoryHashes(config) {
9075
+ const hashes = /* @__PURE__ */ new Set();
9076
+ await collectDurableMemoryHashes(localVikingDataRoot(config), hashes);
9077
+ return hashes;
9078
+ }
9079
+ async function collectDurableMemoryHashes(root, hashes) {
9080
+ let entries;
9081
+ try {
9082
+ entries = await (0, import_promises5.readdir)(root, { withFileTypes: true });
9083
+ } catch (_err) {
9084
+ return;
9025
9085
  }
9026
- if (await hasUncommittedChanges(worktree)) {
9027
- if (options.autoCommit === false) {
9028
- throw new Error(
9029
- `Worktree ${worktree} has uncommitted changes. Commit them yourself or rerun without --no-auto-commit.`
9030
- );
9086
+ for (const entry of entries) {
9087
+ const path = (0, import_node_path5.join)(root, entry.name);
9088
+ if (entry.isDirectory()) {
9089
+ await collectDurableMemoryHashes(path, hashes);
9090
+ continue;
9091
+ }
9092
+ if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md") || !isDurableMemoryPath(path)) {
9093
+ continue;
9094
+ }
9095
+ const content = await readTextIfExists(path);
9096
+ if (content) {
9097
+ const trimmedContent = content.trim();
9098
+ hashes.add(sha256(trimmedContent));
9099
+ hashes.add(sha256(comparableMemoryText(trimmedContent)));
9031
9100
  }
9032
- const message = options.message ?? `share: sync ${(/* @__PURE__ */ new Date()).toISOString()}`;
9033
- await stageShareableChanges(dryRun, git, worktree);
9034
- await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
9035
9101
  }
9036
- const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], dryRun);
9037
- await maybeRun(dryRun, git, ["-C", worktree, "fetch", DEFAULT_GIT_REMOTE_NAME]);
9038
- const pullResult = dryRun ? void 0 : await runCommand(git, ["-C", worktree, "pull", "--rebase", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
9039
- if (dryRun) {
9040
- console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "pull", "--rebase", DEFAULT_GIT_REMOTE_NAME])}`);
9041
- } else if (pullResult && pullResult.exitCode !== 0) {
9042
- if (await exists((0, import_node_path9.join)(team.config.gitdir, "rebase-merge")) || await exists((0, import_node_path9.join)(team.config.gitdir, "rebase-apply"))) {
9043
- throw new Error(
9044
- `git pull --rebase reported conflicts in ${worktree}. The worktree is in a rebase-in-progress state.
9045
- Resolve the conflicts in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then re-run \`threadnote share sync\`.`
9046
- );
9102
+ }
9103
+ function isDurableMemoryPath(path) {
9104
+ return path.split(import_node_path5.sep).includes("memories");
9105
+ }
9106
+ async function childDirectoryNames(path) {
9107
+ let entries;
9108
+ try {
9109
+ entries = await (0, import_promises5.readdir)(path, { withFileTypes: true });
9110
+ } catch (_err) {
9111
+ return [];
9112
+ }
9113
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
9114
+ }
9115
+ async function readTextIfExists(path) {
9116
+ try {
9117
+ const pathStat = await (0, import_promises5.stat)(path);
9118
+ if (!pathStat.isFile()) {
9119
+ return void 0;
9047
9120
  }
9048
- throw new Error(
9049
- `git pull --rebase failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
9050
- );
9121
+ return await (0, import_promises5.readFile)(path, "utf8");
9122
+ } catch (_err) {
9123
+ return void 0;
9051
9124
  }
9052
- const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], dryRun);
9053
- if (!dryRun && beforeRev && afterRev && beforeRev !== afterRev) {
9054
- const changes = await listChangedFiles(worktree, beforeRev, afterRev);
9055
- await applyChangesToOpenViking(config, team.config, changes);
9056
- console.log(`Reindexed ${changes.length} file change(s) into OpenViking.`);
9057
- } else if (!dryRun) {
9058
- console.log("No upstream changes to reindex.");
9125
+ }
9126
+ function sensitiveMemoryReason(text) {
9127
+ const patterns = [
9128
+ { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
9129
+ { name: "API key", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
9130
+ { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
9131
+ { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
9132
+ { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ }
9133
+ ];
9134
+ return patterns.find((pattern) => pattern.regex.test(text))?.name;
9135
+ }
9136
+ function comparableMemoryText(text) {
9137
+ const trimmed = text.trim();
9138
+ if (!trimmed.startsWith("MEMORY\n")) {
9139
+ return trimmed;
9059
9140
  }
9060
- if (options.push !== false) {
9061
- await maybeRun(dryRun, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
9141
+ const separatorIndex = trimmed.indexOf("\n\n");
9142
+ return separatorIndex === -1 ? trimmed : trimmed.slice(separatorIndex + 2).trim();
9143
+ }
9144
+ function legacySourceLabel(candidate) {
9145
+ return `${candidate.sourceAccount}/${candidate.sourceSession}/${candidate.sourceArchive}`;
9146
+ }
9147
+ function localVikingDataRoot(config) {
9148
+ return (0, import_node_path5.join)(config.agentContextHome, "data", "viking");
9149
+ }
9150
+ function localUserMemoriesRoot(config) {
9151
+ return (0, import_node_path5.join)(localVikingDataRoot(config), config.account, "user", uriSegment(config.user), "memories");
9152
+ }
9153
+ function uniqueStrings(values) {
9154
+ return [...new Set(values)].sort();
9155
+ }
9156
+ function isResourceBusy(stderr, stdout) {
9157
+ const output2 = `${stderr}
9158
+ ${stdout}`.toLowerCase();
9159
+ return output2.includes("resource is busy") || output2.includes("resource is being processed");
9160
+ }
9161
+ async function buildHandoff(options) {
9162
+ const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]) ?? getInvocationCwd();
9163
+ const branch = await gitValue(["branch", "--show-current"], repoRoot) ?? "unknown";
9164
+ const status = await gitValue(["status", "--short"], repoRoot) ?? "";
9165
+ const diffStat = await gitValue(["diff", "--stat", "HEAD"], repoRoot) ?? "";
9166
+ const touchedFiles = await gitTouchedFiles(repoRoot);
9167
+ const repoName = (0, import_node_path5.basename)(repoRoot);
9168
+ const metadata = {
9169
+ kind: "handoff",
9170
+ project: normalizeOptionalMetadata(options.project) ?? repoName,
9171
+ sourceAgentClient: options.sourceAgentClient ?? "codex",
9172
+ status: "active",
9173
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9174
+ topic: normalizeOptionalMetadata(options.topic)
9175
+ };
9176
+ const bodyText = [
9177
+ `repo: ${repoName}`,
9178
+ `repo_path: ${repoRoot}`,
9179
+ `branch: ${branch || "unknown"}`,
9180
+ `task: ${options.task ?? "unspecified"}`,
9181
+ "",
9182
+ "files_touched:",
9183
+ formatBlock(touchedFiles, "- none"),
9184
+ "",
9185
+ "git_status:",
9186
+ formatBlock(status, "- clean"),
9187
+ "",
9188
+ "diff_stat:",
9189
+ formatBlock(diffStat, "- none"),
9190
+ "",
9191
+ "tests:",
9192
+ options.tests ?? "- not recorded",
9193
+ "",
9194
+ "blockers:",
9195
+ options.blockers ?? "- none recorded",
9196
+ "",
9197
+ "next_step:",
9198
+ options.nextStep ?? "- inspect the current repo state and continue from this handoff"
9199
+ ].join("\n");
9200
+ return { bodyText, metadata };
9201
+ }
9202
+ async function gitTouchedFiles(cwd) {
9203
+ const changedFiles = await gitValue(["diff", "--name-only", "HEAD"], cwd);
9204
+ const untrackedFiles = await gitValue(["ls-files", "--others", "--exclude-standard"], cwd);
9205
+ const files = /* @__PURE__ */ new Set();
9206
+ for (const value of [changedFiles, untrackedFiles]) {
9207
+ for (const line of (value ?? "").split("\n")) {
9208
+ const trimmed = line.trim();
9209
+ if (trimmed) {
9210
+ files.add(trimmed);
9211
+ }
9212
+ }
9062
9213
  }
9214
+ return [...files].sort().join("\n");
9063
9215
  }
9064
- async function stageShareableChanges(dryRun, git, worktree) {
9065
- const pathspecs = [":(top)README.md", ":(top).gitignore", ...SHAREABLE_MEMORY_KIND_DIRS.map((dir) => `:(top)${dir}`)];
9066
- await maybeRun(dryRun, git, ["-C", worktree, "add", "--", ...pathspecs], { allowFailure: true });
9216
+ function formatBlock(value, emptyValue) {
9217
+ const trimmed = value.trim();
9218
+ if (!trimmed) {
9219
+ return emptyValue;
9220
+ }
9221
+ return trimmed.split("\n").map((line) => `- ${line}`).join("\n");
9067
9222
  }
9068
- async function runSharePublish(config, sourceUri, options) {
9069
- assertVikingUri(sourceUri);
9070
- const team = await resolveTeam(config, options.team);
9071
- const dryRun = options.dryRun === true;
9072
- const preview = options.preview === true;
9073
- if (isInSharedNamespace(config, sourceUri)) {
9074
- throw new Error(`Memory ${sourceUri} is already in the shared namespace.`);
9223
+
9224
+ // src/update-check.ts
9225
+ var import_node_child_process2 = require("node:child_process");
9226
+ var import_promises6 = require("node:fs/promises");
9227
+ var import_node_path6 = require("node:path");
9228
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
9229
+ var NPM_LATEST_URL = "https://registry.npmjs.org/threadnote/latest";
9230
+ var FETCH_TIMEOUT_MS = 3e3;
9231
+ async function checkForThreadnoteUpdate(args) {
9232
+ if (args.currentVersion === "unknown") {
9233
+ return void 0;
9075
9234
  }
9076
- const ov = await openVikingCliForMode(dryRun);
9077
- const rawContent = await readMemoryContent(config, ov, sourceUri, dryRun);
9078
- const stripped = stripPersonalProvenance(rawContent);
9079
- const scrub = applyScrubber(stripped, { redact: options.redact === true });
9080
- const targetUri = sharedUriFor(config, sourceUri, team.name);
9081
- if (preview) {
9082
- console.log(`PREVIEW source: ${sourceUri}`);
9083
- console.log(`PREVIEW destination: ${targetUri}`);
9084
- if (scrub.blocker) {
9085
- console.log(
9086
- `PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or rerun with --redact for soft-leak patterns.`
9087
- );
9235
+ const cached = await readUpdateCache(args.cachePath);
9236
+ if (cached && isCacheFresh(cached)) {
9237
+ return compareVersions(args.currentVersion, cached.latestVersion);
9238
+ }
9239
+ const fresh = await fetchLatestVersion();
9240
+ if (fresh) {
9241
+ await writeUpdateCache(args.cachePath, {
9242
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
9243
+ latestVersion: fresh,
9244
+ version: 1
9245
+ });
9246
+ return compareVersions(args.currentVersion, fresh);
9247
+ }
9248
+ if (cached) {
9249
+ return compareVersions(args.currentVersion, cached.latestVersion);
9250
+ }
9251
+ return void 0;
9252
+ }
9253
+ function spawnDetachedAutoUpdate() {
9254
+ try {
9255
+ const entry = process.argv[1];
9256
+ if (typeof entry !== "string" || entry.length === 0) {
9088
9257
  return;
9089
9258
  }
9090
- for (const redaction of scrub.redactions) {
9091
- console.log(`PREVIEW redact: ${redaction.count}\xD7 ${redaction.name}`);
9092
- }
9093
- console.log("-----BEGIN PREVIEW-----");
9094
- console.log(scrub.cleaned);
9095
- console.log("-----END PREVIEW-----");
9096
- return;
9259
+ const child = (0, import_node_child_process2.spawn)(process.execPath, [entry, "update", "--yes"], {
9260
+ detached: true,
9261
+ stdio: "ignore"
9262
+ });
9263
+ child.unref();
9264
+ } catch {
9097
9265
  }
9098
- if (scrub.blocker) {
9099
- throw new Error(
9100
- `Refusing to publish ${sourceUri}: possible ${scrub.blocker}. Strip the sensitive value or pass --redact for soft-leak patterns.`
9101
- );
9266
+ }
9267
+ function compareVersions(currentVersion, latestVersion) {
9268
+ return {
9269
+ currentVersion,
9270
+ latestVersion,
9271
+ outdated: isNewerVersion(latestVersion, currentVersion)
9272
+ };
9273
+ }
9274
+ function isNewerVersion(candidate, baseline) {
9275
+ const candidateParts = parseVersion(candidate);
9276
+ const baselineParts = parseVersion(baseline);
9277
+ for (let index = 0; index < 3; index += 1) {
9278
+ if (candidateParts[index] !== baselineParts[index]) {
9279
+ return candidateParts[index] > baselineParts[index];
9280
+ }
9102
9281
  }
9103
- for (const redaction of scrub.redactions) {
9104
- console.log(`Redacted ${redaction.count}\xD7 ${redaction.name} before publish.`);
9282
+ return false;
9283
+ }
9284
+ function parseVersion(value) {
9285
+ const parts = value.split(".").map((part) => parseInt(part, 10));
9286
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
9287
+ }
9288
+ function isCacheFresh(cache) {
9289
+ const checkedAt = new Date(cache.checkedAt).getTime();
9290
+ return Number.isFinite(checkedAt) && Date.now() - checkedAt < CACHE_TTL_MS;
9291
+ }
9292
+ async function readUpdateCache(cachePath) {
9293
+ try {
9294
+ const raw = await (0, import_promises6.readFile)(cachePath, "utf8");
9295
+ const parsed = JSON.parse(raw);
9296
+ if (parsed.version !== 1 || typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
9297
+ return void 0;
9298
+ }
9299
+ return { checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion, version: 1 };
9300
+ } catch {
9301
+ return void 0;
9105
9302
  }
9106
- const content = scrub.cleaned;
9107
- if (!dryRun && await vikingResourceExists2(ov, config, targetUri)) {
9108
- throw new Error(
9109
- `Refusing to publish: ${targetUri} already exists in the shared namespace. Inspect it via threadnote read; if it should be replaced, forget the existing shared copy first.`
9110
- );
9303
+ }
9304
+ async function writeUpdateCache(cachePath, contents) {
9305
+ try {
9306
+ await (0, import_promises6.mkdir)((0, import_node_path6.dirname)(cachePath), { recursive: true });
9307
+ await (0, import_promises6.writeFile)(cachePath, `${JSON.stringify(contents)}
9308
+ `, { encoding: "utf8", mode: 384 });
9309
+ } catch {
9111
9310
  }
9112
- await ensureSharedDirectoryChain(config, ov, targetUri, dryRun);
9113
- await writeMemoryFile(config, ov, targetUri, content, "create", dryRun);
9114
- await removeWithRollback(config, ov, sourceUri, targetUri, team.config.worktree, dryRun, "publish");
9115
- const git = await requiredExecutable("git");
9116
- const worktree = team.config.worktree;
9117
- const relativePath = vikingUriToWorktreeRelative(config, targetUri, team.name);
9118
- const message = options.message ?? `share: publish ${relativePath}`;
9119
- await maybeRun(dryRun, git, ["-C", worktree, "add", "--", relativePath]);
9120
- await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
9121
- if (options.push !== false) {
9122
- const pushResult = dryRun ? void 0 : await runCommand(git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
9123
- if (dryRun) {
9124
- console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME])}`);
9125
- } else if (pushResult && pushResult.exitCode !== 0) {
9126
- const detail = pushResult.stderr.trim() || pushResult.stdout.trim() || "unknown error";
9127
- throw new Error(
9128
- `Memory was committed locally but git push failed: ${detail}
9129
- Resolve the remote issue (auth, network, branch protection), then run: threadnote share sync`
9130
- );
9311
+ }
9312
+ async function fetchLatestVersion() {
9313
+ try {
9314
+ const response = await fetch(NPM_LATEST_URL, {
9315
+ headers: { accept: "application/json" },
9316
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
9317
+ });
9318
+ if (!response.ok) {
9319
+ return void 0;
9131
9320
  }
9321
+ const data = await response.json();
9322
+ return typeof data.version === "string" && data.version.length > 0 ? data.version : void 0;
9323
+ } catch {
9324
+ return void 0;
9132
9325
  }
9133
- console.log(`Published ${sourceUri} -> ${targetUri}`);
9134
9326
  }
9135
- async function runShareUnpublish(config, sourceUri, options) {
9136
- assertVikingUri(sourceUri);
9137
- const team = await resolveTeam(config, options.team);
9138
- const dryRun = options.dryRun === true;
9139
- if (!isInTeamNamespace(config, sourceUri, team.name)) {
9140
- throw new Error(`Memory ${sourceUri} is not in team "${team.name}" shared namespace.`);
9141
- }
9142
- const ov = await openVikingCliForMode(dryRun);
9143
- const content = await readMemoryContent(config, ov, sourceUri, dryRun);
9144
- const targetUri = personalUriFor(config, sourceUri, team.name);
9145
- if (!dryRun && await vikingResourceExists2(ov, config, targetUri)) {
9146
- throw new Error(
9147
- `Refusing to unpublish: a personal memory already exists at ${targetUri}. Move or forget it first, then retry.`
9148
- );
9327
+
9328
+ // src/version.ts
9329
+ var import_node_fs4 = require("node:fs");
9330
+ var import_node_path7 = require("node:path");
9331
+ var cachedVersion;
9332
+ function getThreadnoteVersion() {
9333
+ if (cachedVersion !== void 0) {
9334
+ return cachedVersion;
9149
9335
  }
9150
- await writeMemoryFile(config, ov, targetUri, content, "create", dryRun);
9151
- await removeWithRollback(config, ov, sourceUri, targetUri, team.config.worktree, dryRun, "unpublish");
9152
- const git = await requiredExecutable("git");
9153
- const worktree = team.config.worktree;
9154
- const relativePath = vikingUriToWorktreeRelative(config, sourceUri, team.name);
9155
- const message = options.message ?? `share: unpublish ${relativePath}`;
9156
- await maybeRun(dryRun, git, ["-C", worktree, "rm", relativePath], { allowFailure: true });
9157
- await maybeRun(dryRun, git, ["-C", worktree, "commit", "-m", message], { allowFailure: true });
9158
- if (options.push !== false) {
9159
- await maybeRun(dryRun, git, ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME], { allowFailure: true });
9336
+ try {
9337
+ const packageJsonPath = (0, import_node_path7.join)(__dirname, "..", "package.json");
9338
+ const parsed = JSON.parse((0, import_node_fs4.readFileSync)(packageJsonPath, "utf8"));
9339
+ cachedVersion = typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "unknown";
9340
+ } catch {
9341
+ cachedVersion = "unknown";
9160
9342
  }
9161
- console.log(`Unpublished ${sourceUri} -> ${targetUri}`);
9343
+ return cachedVersion;
9162
9344
  }
9163
- async function runShareList(config, _options) {
9164
- const teams = await readTeamsFile(config);
9165
- const entries = Object.values(teams.teams);
9166
- if (entries.length === 0) {
9167
- console.log("No shared teams configured. Run: threadnote share init <remote-url>");
9168
- return;
9345
+
9346
+ // src/hooks.ts
9347
+ var MANAGED_HOOKS = [
9348
+ {
9349
+ event: "PreCompact",
9350
+ command: HOOK_PRE_COMPACT_COMMAND,
9351
+ description: "Auto-store a handoff snapshot before context compaction so the next turn can recall it."
9352
+ },
9353
+ {
9354
+ event: "SessionStart",
9355
+ command: HOOK_SESSION_START_COMMAND,
9356
+ description: "Pre-load the latest handoff for the current repo into the new session context."
9169
9357
  }
9170
- for (const team of entries) {
9171
- const marker = team.name === teams.defaultTeam ? " (default)" : "";
9172
- console.log(`- ${team.name}${marker}`);
9173
- console.log(` remote: ${team.remote}`);
9174
- console.log(` worktree: ${portablePath(team.worktree)}`);
9175
- console.log(` gitdir: ${portablePath(team.gitdir)}`);
9176
- console.log(` added: ${team.addedAt}`);
9358
+ ];
9359
+ async function runHooksInstall(config, agent, options) {
9360
+ const apply = options.apply === true && options.dryRun !== true;
9361
+ const remove = options.remove === true;
9362
+ switch (agent) {
9363
+ case "claude":
9364
+ await runClaudeHooksInstall({ apply, remove });
9365
+ return;
9366
+ case "codex":
9367
+ printCodexHooksNotice(remove);
9368
+ return;
9369
+ case "cursor":
9370
+ printNoHooksSupported("cursor", remove);
9371
+ return;
9372
+ case "copilot":
9373
+ printNoHooksSupported("copilot", remove);
9374
+ return;
9177
9375
  }
9178
9376
  }
9179
- async function runShareRemove(config, options) {
9180
- const team = await resolveTeam(config, options.team);
9181
- const dryRun = options.dryRun === true;
9182
- const teamsFile = await readTeamsFile(config);
9183
- const remaining = {};
9184
- for (const [name, value] of Object.entries(teamsFile.teams)) {
9185
- if (name !== team.name) {
9186
- remaining[name] = value;
9187
- }
9377
+ async function runClaudeHooksInstall(options) {
9378
+ const path = expandPath(CLAUDE_SETTINGS_PATH);
9379
+ const existingRaw = await exists(path) ? await (0, import_promises7.readFile)(path, "utf8") : "{}";
9380
+ const parsed = parseJsonConfigObject(existingRaw) ?? {};
9381
+ const next = options.remove ? withoutThreadnoteHooks(parsed) : withThreadnoteHooks(parsed);
9382
+ const before = JSON.stringify(parsed);
9383
+ const after = JSON.stringify(next);
9384
+ if (before === after) {
9385
+ console.log(`Claude hooks already ${options.remove ? "absent" : "managed"} in ${path}.`);
9386
+ return;
9188
9387
  }
9189
- const remainingNames = Object.keys(remaining);
9190
- const nextDefault = teamsFile.defaultTeam === team.name ? remainingNames[0] : teamsFile.defaultTeam;
9191
- const updated = { defaultTeam: nextDefault, teams: remaining, version: TEAMS_FILE_VERSION };
9192
- if (dryRun) {
9193
- console.log(`Would update teams file: ${teamsFilePath(config)}`);
9194
- } else {
9195
- await writeTeamsFile(config, updated);
9196
- console.log(`Removed team "${team.name}" from teams.json.`);
9388
+ console.log(`${options.apply ? "Updating" : "Would update"} ${path}:`);
9389
+ for (const entry of MANAGED_HOOKS) {
9390
+ console.log(` ${options.remove ? "-" : "+"} ${entry.event}: ${entry.command}`);
9197
9391
  }
9198
- if (options.keepFiles !== true) {
9199
- await removePath(team.config.worktree, "shared worktree", dryRun);
9200
- await removePath(team.config.gitdir, "shared gitdir", dryRun);
9201
- } else {
9202
- console.log(`Keeping files at ${portablePath(team.config.worktree)} and ${portablePath(team.config.gitdir)}`);
9392
+ if (!options.apply) {
9393
+ console.log("\nRe-run with --apply to actually modify the file.");
9394
+ return;
9203
9395
  }
9396
+ await (0, import_promises7.mkdir)((0, import_node_path8.dirname)(path), { recursive: true });
9397
+ const serialized = `${JSON.stringify(next, void 0, 2)}
9398
+ `;
9399
+ await (0, import_promises7.writeFile)(path, serialized, { encoding: "utf8", mode: 384 });
9400
+ await (0, import_promises7.chmod)(path, 384);
9401
+ console.log(`${options.remove ? "Removed" : "Installed"} threadnote-managed Claude hooks.`);
9204
9402
  }
9205
- function normalizeTeamName(input2) {
9206
- const candidate = (input2 ?? "default").trim();
9207
- if (!candidate) {
9208
- return "default";
9403
+ function withThreadnoteHooks(input2) {
9404
+ const hooks = ensureMutableObject(input2.hooks);
9405
+ for (const entry of MANAGED_HOOKS) {
9406
+ const list = ensureMutableArray(hooks[entry.event]).filter((item) => !isManagedThreadnoteEntry(item));
9407
+ list.push({
9408
+ [THREADNOTE_HOOK_MARKER]: THREADNOTE_HOOK_MARKER_VALUE,
9409
+ matcher: "",
9410
+ hooks: [{ type: "command", command: entry.command }]
9411
+ });
9412
+ hooks[entry.event] = list;
9209
9413
  }
9210
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(candidate) || /^\.+$/.test(candidate)) {
9211
- throw new Error(
9212
- `Invalid team name "${input2}". Team names must start with a lowercase letter or digit and contain only [a-z0-9._-]. Single-dot or dot-only names are rejected so they don't collapse to the shared-root or parent directory.`
9213
- );
9414
+ return { ...input2, hooks };
9415
+ }
9416
+ function withoutThreadnoteHooks(input2) {
9417
+ if (!isJsonObject(input2.hooks)) {
9418
+ return input2;
9419
+ }
9420
+ const hooks = {};
9421
+ for (const [event, value] of Object.entries(input2.hooks)) {
9422
+ if (!Array.isArray(value)) {
9423
+ hooks[event] = value;
9424
+ continue;
9425
+ }
9426
+ const filtered = value.filter((item) => !isManagedThreadnoteEntry(item));
9427
+ if (filtered.length > 0) {
9428
+ hooks[event] = filtered;
9429
+ }
9214
9430
  }
9215
- return candidate;
9431
+ if (Object.keys(hooks).length === 0) {
9432
+ const next = { ...input2 };
9433
+ delete next.hooks;
9434
+ return next;
9435
+ }
9436
+ return { ...input2, hooks };
9216
9437
  }
9217
- function teamsFilePath(config) {
9218
- return (0, import_node_path9.join)(config.agentContextHome, "share", "teams.json");
9438
+ function isManagedThreadnoteEntry(value) {
9439
+ return isJsonObject(value) && value[THREADNOTE_HOOK_MARKER] === THREADNOTE_HOOK_MARKER_VALUE;
9219
9440
  }
9220
- function teamWorktreePath(config, team) {
9221
- return (0, import_node_path9.join)(
9222
- config.agentContextHome,
9223
- "data",
9224
- "viking",
9225
- config.account,
9226
- "user",
9227
- uriSegment(config.user),
9228
- "memories",
9229
- SHARED_SEGMENT,
9230
- team
9441
+ function ensureMutableObject(value) {
9442
+ return isJsonObject(value) ? { ...value } : {};
9443
+ }
9444
+ function ensureMutableArray(value) {
9445
+ return Array.isArray(value) ? [...value] : [];
9446
+ }
9447
+ function printCodexHooksNotice(remove) {
9448
+ if (remove) {
9449
+ console.log("Codex CLI does not expose a managed hook surface today, so there is nothing to remove.");
9450
+ return;
9451
+ }
9452
+ console.log(
9453
+ [
9454
+ "Codex CLI does not currently expose lifecycle hooks (no PreCompact or SessionStart analog).",
9455
+ "Threadnote already installs Codex user instructions at ~/.codex/AGENTS.md; that remains the active guidance surface.",
9456
+ "If a future Codex release adds hook events, threadnote will pick them up via `install-hooks codex`."
9457
+ ].join("\n")
9231
9458
  );
9232
9459
  }
9233
- function teamGitdirPath(config, team) {
9234
- return (0, import_node_path9.join)(config.agentContextHome, "share", "teams", `${team}.gitdir`);
9460
+ function printNoHooksSupported(agent, remove) {
9461
+ if (remove) {
9462
+ console.log(`${agent} does not expose a hook surface; nothing to remove.`);
9463
+ return;
9464
+ }
9465
+ console.log(
9466
+ [
9467
+ `${agent} does not expose agent-mode hooks today.`,
9468
+ "Threadnote already installs user-level instructions for this agent; that remains the active guidance surface.",
9469
+ "Hooks support will be added if the agent gains a hook surface upstream."
9470
+ ].join("\n")
9471
+ );
9235
9472
  }
9236
- async function readTeamsFile(config) {
9237
- const path = teamsFilePath(config);
9238
- const raw = await readFileIfExists(path);
9239
- if (!raw) {
9240
- return { teams: {}, version: TEAMS_FILE_VERSION };
9473
+ async function hasManagedClaudeHooks() {
9474
+ const path = expandPath(CLAUDE_SETTINGS_PATH);
9475
+ if (!await exists(path)) {
9476
+ return false;
9241
9477
  }
9478
+ const raw = await (0, import_promises7.readFile)(path, "utf8");
9242
9479
  const parsed = parseJsonConfigObject(raw);
9243
- if (!parsed) {
9244
- throw new Error(`Could not parse teams file ${path}`);
9245
- }
9246
- if (typeof parsed.version === "number" && parsed.version > TEAMS_FILE_VERSION) {
9247
- throw new Error(
9248
- `Teams file ${path} was written with version ${parsed.version}; this Threadnote binary understands up to version ${TEAMS_FILE_VERSION}. Upgrade Threadnote (\`threadnote update\`) before continuing.`
9249
- );
9480
+ if (!parsed || !isJsonObject(parsed.hooks)) {
9481
+ return false;
9250
9482
  }
9251
- const teams = {};
9252
- if (typeof parsed.teams === "object" && parsed.teams !== null && !Array.isArray(parsed.teams)) {
9253
- for (const [name, value] of Object.entries(parsed.teams)) {
9254
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
9255
- console.warn(`Skipping non-object team entry "${name}" in ${path}.`);
9256
- continue;
9257
- }
9258
- const entry = value;
9259
- if (typeof entry.remote !== "string" || entry.remote.length === 0) {
9260
- console.warn(`Skipping team entry "${name}" in ${path}: missing or empty "remote" field.`);
9261
- continue;
9262
- }
9263
- teams[name] = {
9264
- addedAt: typeof entry.addedAt === "string" ? entry.addedAt : (/* @__PURE__ */ new Date(0)).toISOString(),
9265
- gitdir: typeof entry.gitdir === "string" ? entry.gitdir : teamGitdirPath(config, name),
9266
- name,
9267
- remote: entry.remote,
9268
- worktree: typeof entry.worktree === "string" ? entry.worktree : teamWorktreePath(config, name)
9269
- };
9483
+ for (const value of Object.values(parsed.hooks)) {
9484
+ if (!Array.isArray(value)) {
9485
+ continue;
9486
+ }
9487
+ if (value.some((item) => isManagedThreadnoteEntry(item))) {
9488
+ return true;
9270
9489
  }
9271
9490
  }
9272
- const defaultTeam = typeof parsed.defaultTeam === "string" ? parsed.defaultTeam : void 0;
9273
- return { defaultTeam, teams, version: TEAMS_FILE_VERSION };
9274
- }
9275
- async function writeTeamsFile(config, contents) {
9276
- const path = teamsFilePath(config);
9277
- await (0, import_promises8.mkdir)((0, import_node_path9.dirname)(path), { recursive: true });
9278
- const serializable = {
9279
- defaultTeam: contents.defaultTeam,
9280
- teams: contents.teams,
9281
- version: contents.version
9282
- };
9283
- await (0, import_promises8.writeFile)(path, `${JSON.stringify(serializable, void 0, 2)}
9284
- `, { encoding: "utf8", mode: 384 });
9285
- }
9286
- async function resolveTeam(config, requested) {
9287
- const teamsFile = await readTeamsFile(config);
9288
- const entries = Object.entries(teamsFile.teams);
9289
- if (entries.length === 0) {
9290
- throw new Error("No shared teams configured. Run: threadnote share init <remote-url>");
9291
- }
9292
- const wantName = requested ? normalizeTeamName(requested) : teamsFile.defaultTeam ?? entries[0][0];
9293
- const found = teamsFile.teams[wantName];
9294
- if (!found) {
9295
- const known = entries.map(([name]) => name).join(", ");
9296
- throw new Error(`Team "${wantName}" is not configured. Known teams: ${known}`);
9297
- }
9298
- return { config: found, name: wantName };
9491
+ return false;
9299
9492
  }
9300
- function shouldSetDefault(options, existing) {
9301
- if (options.setDefault === true) {
9302
- return true;
9493
+ async function runPreCompactHook(config, options = {}) {
9494
+ try {
9495
+ const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]);
9496
+ const project = repoRoot ? (0, import_node_path8.basename)(repoRoot) : "general";
9497
+ await runHandoff(config, {
9498
+ blockers: "- none recorded",
9499
+ dryRun: options.dryRun === true,
9500
+ nextStep: "Continue from this auto-snapshot. A manual `threadnote handoff` will produce a richer write-up if you have more context.",
9501
+ project,
9502
+ sourceAgentClient: "claude",
9503
+ task: "Auto-snapshot captured at Claude PreCompact (deterministic safety net before context compaction).",
9504
+ tests: "- not recorded (auto-snapshot)",
9505
+ topic: HOOK_AUTO_PRECOMPACT_TOPIC
9506
+ });
9507
+ } catch (err) {
9508
+ process.stderr.write(
9509
+ `threadnote pre-compact-hook: snapshot skipped (${err instanceof Error ? err.message : String(err)})
9510
+ `
9511
+ );
9303
9512
  }
9304
- return existing.defaultTeam === void 0;
9305
9513
  }
9306
- async function assertWorktreeUsable(worktree) {
9307
- if (!await exists(worktree)) {
9308
- return;
9309
- }
9310
- if (!await isDirectory(worktree)) {
9311
- throw new Error(`Cannot use ${worktree} as a worktree: not a directory.`);
9312
- }
9313
- const entries = await (0, import_promises8.readdir)(worktree);
9314
- if (entries.length > 0) {
9315
- const preview = entries.slice(0, 5).join(", ");
9316
- const suffix = entries.length > 5 ? `, +${entries.length - 5} more` : "";
9317
- throw new Error(
9318
- `Worktree ${worktree} is not empty (contains: ${preview}${suffix}). Move or remove its contents, then retry threadnote share init.`
9514
+ async function runSessionStartHook(config, options = {}) {
9515
+ try {
9516
+ const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]);
9517
+ if (!repoRoot) {
9518
+ return;
9519
+ }
9520
+ const project = (0, import_node_path8.basename)(repoRoot);
9521
+ await emitUpdateBannerIfOutdated(config);
9522
+ process.stdout.write(`## Threadnote \u2014 latest context for ${project}
9523
+
9524
+ `);
9525
+ await runRecall(config, {
9526
+ dryRun: options.dryRun === true,
9527
+ inferScope: true,
9528
+ nodeLimit: "5",
9529
+ query: `${project} latest handoff durable feature memory`
9530
+ });
9531
+ } catch (err) {
9532
+ process.stderr.write(
9533
+ `threadnote session-start-hook: recall skipped (${err instanceof Error ? err.message : String(err)})
9534
+ `
9319
9535
  );
9320
9536
  }
9321
9537
  }
9322
- async function ingestWorktreeFiles(config, team, initialMode) {
9323
- const ov = await openVikingCliForMode(false);
9324
- const files = await walkMemoryFiles(team.worktree);
9325
- for (const file of files) {
9326
- const uri = workfileToVikingUri(config, team, file);
9327
- await ensureSharedDirectoryChain(config, ov, uri, false);
9328
- await ingestSingleFile(ov, config, uri, file, initialMode);
9538
+ async function emitUpdateBannerIfOutdated(config) {
9539
+ try {
9540
+ const result = await checkForThreadnoteUpdate({
9541
+ cachePath: (0, import_node_path8.join)(config.agentContextHome, ".update-state.json"),
9542
+ currentVersion: getThreadnoteVersion()
9543
+ });
9544
+ if (!result || !result.outdated) {
9545
+ return;
9546
+ }
9547
+ if (process.env.THREADNOTE_AUTO_UPDATE === "1") {
9548
+ process.stdout.write(
9549
+ `[threadnote] v${result.latestVersion} available (current v${result.currentVersion}). Auto-updating in the background; the new version takes effect next session.
9550
+
9551
+ `
9552
+ );
9553
+ spawnDetachedAutoUpdate();
9554
+ return;
9555
+ }
9556
+ process.stdout.write(
9557
+ `[threadnote] v${result.latestVersion} available (current v${result.currentVersion}). Run: threadnote update
9558
+
9559
+ `
9560
+ );
9561
+ } catch {
9329
9562
  }
9330
- return files.length;
9331
9563
  }
9332
- async function walkMemoryFiles(root) {
9333
- const out = [];
9334
- async function visit(path, depth) {
9335
- let entries;
9336
- try {
9337
- entries = await (0, import_promises8.readdir)(path, { withFileTypes: true });
9338
- } catch (err) {
9339
- console.warn(`Skipping ${path} during shared-tree walk: ${err instanceof Error ? err.message : String(err)}`);
9340
- return;
9564
+
9565
+ // src/seeding.ts
9566
+ var import_promises8 = require("node:fs/promises");
9567
+ var import_node_path9 = require("node:path");
9568
+ async function runSeed(config, options) {
9569
+ const manifest = await readSeedManifest(config.manifestPath);
9570
+ const ignorePatterns = await loadIgnorePatterns();
9571
+ const ov = await openVikingCliForMode(options.dryRun === true);
9572
+ const statePath = (0, import_node_path9.join)(config.agentContextHome, SEED_STATE_FILE);
9573
+ const state = options.force === true ? { files: {}, version: 1 } : await readSeedState(statePath);
9574
+ const projects = filterProjects(manifest.projects, options.only);
9575
+ let importedCount = 0;
9576
+ let skippedCount = 0;
9577
+ let unchangedCount = 0;
9578
+ for (const project of projects) {
9579
+ const projectRoot = expandPath(project.path);
9580
+ if (!await exists(projectRoot)) {
9581
+ console.log(`WARN project missing: ${project.name} (${projectRoot})`);
9582
+ continue;
9341
9583
  }
9342
- for (const entry of entries) {
9343
- if (entry.name === ".git") {
9344
- continue;
9345
- }
9346
- const full = (0, import_node_path9.join)(path, entry.name);
9347
- if (entry.isDirectory()) {
9348
- if (depth === 0 && !SHAREABLE_MEMORY_KIND_DIRS.includes(entry.name)) {
9349
- continue;
9350
- }
9351
- await visit(full, depth + 1);
9352
- continue;
9353
- }
9354
- if (!entry.isFile()) {
9355
- continue;
9356
- }
9357
- if (depth === 0) {
9584
+ const candidates = await collectSeedCandidates(project, projectRoot, ignorePatterns);
9585
+ for (const candidate of candidates) {
9586
+ const fileStat = await statSeedFile(candidate.filePath);
9587
+ const recorded = state.files[candidate.destinationUri];
9588
+ if (fileStat && recorded && recorded.mtimeMs === fileStat.mtimeMs && recorded.size === fileStat.size) {
9589
+ unchangedCount += 1;
9358
9590
  continue;
9359
9591
  }
9360
- if (!entry.name.endsWith(".md")) {
9592
+ const importPath = await prepareSeedFile(config, candidate, options.dryRun === true);
9593
+ if (!importPath) {
9594
+ skippedCount += 1;
9361
9595
  continue;
9362
9596
  }
9363
- out.push(full);
9597
+ const args = withIdentity(config, ["add-resource", importPath, "--to", candidate.destinationUri, "--wait"]);
9598
+ await maybeRun(options.dryRun === true, ov, args);
9599
+ importedCount += 1;
9600
+ if (fileStat && options.dryRun !== true) {
9601
+ state.files[candidate.destinationUri] = { mtimeMs: fileStat.mtimeMs, size: fileStat.size };
9602
+ }
9364
9603
  }
9365
9604
  }
9366
- await visit(root, 0);
9367
- return out;
9368
- }
9369
- function workfileToVikingUri(config, team, filePath) {
9370
- const rel = (0, import_node_path9.relative)(team.worktree, filePath).split(import_node_path9.sep).join("/");
9371
- return `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team.name}/${rel}`;
9372
- }
9373
- function isInSharedNamespace(config, uri) {
9374
- return uri.startsWith(`viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/`);
9375
- }
9376
- function isInTeamNamespace(config, uri, team) {
9377
- return uri.startsWith(`viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`);
9378
- }
9379
- function sharedUriFor(config, personalUri, team) {
9380
- const prefix = `viking://user/${uriSegment(config.user)}/memories/`;
9381
- if (!personalUri.startsWith(prefix)) {
9382
- throw new Error(`Refusing to publish memory outside the current user namespace: ${personalUri}`);
9605
+ if (options.dryRun !== true) {
9606
+ await writeSeedState(statePath, state);
9383
9607
  }
9384
- const rest = personalUri.slice(prefix.length);
9385
- return `${prefix}${SHARED_SEGMENT}/${team}/${rest}`;
9608
+ console.log(
9609
+ `Seed complete: ${importedCount} candidate(s), ${unchangedCount} unchanged, ${skippedCount} skipped for safety.`
9610
+ );
9386
9611
  }
9387
- function personalUriFor(config, sharedUri, team) {
9388
- const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`;
9389
- if (!sharedUri.startsWith(prefix)) {
9390
- throw new Error(`Refusing to unpublish a URI outside team "${team}" shared namespace: ${sharedUri}`);
9612
+ function filterProjects(projects, only) {
9613
+ if (!only || only.length === 0) {
9614
+ return projects;
9391
9615
  }
9392
- const rest = sharedUri.slice(prefix.length);
9393
- return `viking://user/${uriSegment(config.user)}/memories/${rest}`;
9616
+ const known = new Set(projects.map((project) => project.name));
9617
+ const missing = only.filter((name) => !known.has(name));
9618
+ if (missing.length > 0) {
9619
+ const all = [...known].join(", ");
9620
+ throw new Error(`Unknown project(s) in --only: ${missing.join(", ")}. Manifest projects: ${all}`);
9621
+ }
9622
+ const want = new Set(only);
9623
+ return projects.filter((project) => want.has(project.name));
9394
9624
  }
9395
- function vikingUriToWorktreeRelative(config, uri, team) {
9396
- const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/${team}/`;
9397
- if (!uri.startsWith(prefix)) {
9398
- throw new Error(`URI ${uri} is not inside team "${team}" shared subtree.`);
9625
+ async function statSeedFile(path) {
9626
+ try {
9627
+ const result = await (0, import_promises8.stat)(path);
9628
+ return { mtimeMs: result.mtimeMs, size: result.size };
9629
+ } catch (_err) {
9630
+ return void 0;
9399
9631
  }
9400
- return uri.slice(prefix.length);
9401
9632
  }
9402
- function stripPersonalProvenance(content) {
9403
- const lines = content.split("\n");
9404
- let headerEnd = lines.length;
9405
- for (let index = 0; index < lines.length; index += 1) {
9406
- if (lines[index].trim() === "") {
9407
- headerEnd = index;
9408
- break;
9633
+ async function readSeedState(path) {
9634
+ try {
9635
+ const raw = await (0, import_promises8.readFile)(path, "utf8");
9636
+ const parsed = JSON.parse(raw);
9637
+ if (parsed.version !== 1 || !isJsonObject(parsed.files)) {
9638
+ return { files: {}, version: 1 };
9409
9639
  }
9410
- }
9411
- const cleaned = [];
9412
- for (let index = 0; index < headerEnd; index += 1) {
9413
- if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
9414
- continue;
9640
+ const files = {};
9641
+ for (const [uri, entry] of Object.entries(parsed.files)) {
9642
+ if (isJsonObject(entry) && typeof entry.mtimeMs === "number" && typeof entry.size === "number") {
9643
+ files[uri] = { mtimeMs: entry.mtimeMs, size: entry.size };
9644
+ }
9415
9645
  }
9416
- cleaned.push(lines[index]);
9417
- }
9418
- for (let index = headerEnd; index < lines.length; index += 1) {
9419
- cleaned.push(lines[index]);
9646
+ return { files, version: 1 };
9647
+ } catch (_err) {
9648
+ return { files: {}, version: 1 };
9420
9649
  }
9421
- return cleaned.join("\n");
9422
9650
  }
9423
- async function readMemoryContent(config, ov, uri, dryRun) {
9424
- const args = withIdentity(config, ["read", uri]);
9425
- if (dryRun) {
9426
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
9427
- return "<dry-run memory body>";
9428
- }
9429
- const result = await runCommand(ov, args);
9430
- if (!result.stdout.trim()) {
9431
- throw new Error(`Refusing to publish empty memory at ${uri}`);
9432
- }
9433
- return result.stdout;
9651
+ async function writeSeedState(path, state) {
9652
+ await ensureDirectory((0, import_node_path9.dirname)(path), false);
9653
+ await (0, import_promises8.writeFile)(path, `${JSON.stringify(state, void 0, 2)}
9654
+ `, { encoding: "utf8", mode: 384 });
9434
9655
  }
9435
- async function ensureSharedDirectoryChain(config, ov, memoryUri, dryRun) {
9436
- const directoryUri = parentUri(memoryUri);
9437
- for (const uri of sharedDirectoryChain(config, directoryUri)) {
9438
- const args = withIdentity(config, ["stat", uri]);
9439
- if (dryRun) {
9440
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
9441
- continue;
9442
- }
9443
- const statResult = await runCommand(ov, args, { allowFailure: true });
9444
- if (statResult.exitCode === 0) {
9656
+ async function runInitManifest(config, options) {
9657
+ const manifestPath = expandPath(
9658
+ options.path ?? process.env.THREADNOTE_MANIFEST ?? (0, import_node_path9.join)(config.agentContextHome, USER_MANIFEST_NAME)
9659
+ );
9660
+ const repoInputs = options.repo && options.repo.length > 0 ? options.repo : [getInvocationCwd()];
9661
+ const existingManifest = options.replace === true || !await exists(manifestPath) ? void 0 : await readSeedManifest(manifestPath);
9662
+ const existingProjects = existingManifest?.projects ?? [];
9663
+ const projects = [...existingProjects];
9664
+ const seen = /* @__PURE__ */ new Set();
9665
+ for (const project of existingProjects) {
9666
+ seen.add(await projectIdentity(project.path));
9667
+ }
9668
+ for (const repoInput of repoInputs) {
9669
+ const repoRoot = await resolveRepoRoot(repoInput);
9670
+ const identity = await projectIdentity(repoRoot);
9671
+ if (seen.has(identity)) {
9672
+ console.log(`Already in manifest: ${repoRoot}`);
9445
9673
  continue;
9446
9674
  }
9447
- await maybeRun(false, ov, withIdentity(config, ["mkdir", uri, "--description", "Threadnote shared memories."]));
9675
+ seen.add(identity);
9676
+ projects.push(projectManifestForRepo(repoRoot, projects));
9448
9677
  }
9449
- }
9450
- function parentUri(uri) {
9451
- const lastSlash = uri.lastIndexOf("/");
9452
- return lastSlash === -1 ? uri : uri.slice(0, lastSlash);
9453
- }
9454
- function sharedDirectoryChain(config, directoryUri) {
9455
- const prefix = `viking://user/${uriSegment(config.user)}/memories/${SHARED_SEGMENT}/`;
9456
- if (!directoryUri.startsWith(prefix)) {
9457
- return [directoryUri];
9678
+ const outputManifest = {
9679
+ version: 1,
9680
+ projects: projects.map((project) => ({
9681
+ name: project.name,
9682
+ path: project.path,
9683
+ uri: project.uri,
9684
+ seed: [...project.seed]
9685
+ }))
9686
+ };
9687
+ if (existingManifest?.futureMonorepo) {
9688
+ const futureMonorepoKey = "future_monorepo";
9689
+ const pathCandidatesKey = "path_candidates";
9690
+ outputManifest[futureMonorepoKey] = {
9691
+ [pathCandidatesKey]: [...existingManifest.futureMonorepo.pathCandidates],
9692
+ uri: existingManifest.futureMonorepo.uri
9693
+ };
9458
9694
  }
9459
- const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
9460
- const chain = [];
9461
- for (let index = 1; index <= parts.length; index += 1) {
9462
- chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
9695
+ const output2 = jsYaml.dump(outputManifest, { lineWidth: 120, noRefs: true });
9696
+ if (options.dryRun === true) {
9697
+ console.log(`# Would write ${manifestPath}`);
9698
+ console.log(output2.trimEnd());
9699
+ return;
9463
9700
  }
9464
- return chain;
9701
+ await ensureDirectory((0, import_node_path9.dirname)(manifestPath), false);
9702
+ await (0, import_promises8.writeFile)(manifestPath, output2, { encoding: "utf8", mode: 384 });
9703
+ await (0, import_promises8.chmod)(manifestPath, 384);
9704
+ console.log(`Wrote manifest: ${manifestPath}`);
9705
+ console.log("Seed with:");
9706
+ console.log(" threadnote seed --dry-run");
9707
+ console.log(" threadnote seed");
9465
9708
  }
9466
- async function writeMemoryFile(config, ov, uri, content, initialMode, dryRun) {
9467
- if (dryRun) {
9468
- const args = withIdentity(config, [
9469
- "write",
9470
- uri,
9471
- "--from-file",
9472
- "<staged temp file>",
9473
- "--mode",
9474
- initialMode,
9475
- "--wait",
9476
- "--timeout",
9477
- "120"
9478
- ]);
9479
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
9480
- return;
9709
+ async function runSeedSkills(config, options) {
9710
+ const ov = await openVikingCliForMode(options.dryRun === true);
9711
+ const skills = await collectSkillCandidates(config);
9712
+ const nativeMode = options.native === true;
9713
+ console.log(
9714
+ nativeMode ? "Skill seed mode: native OpenViking skills. This requires a working VLM config." : "Skill seed mode: resource catalog. Use --native only after configuring a working VLM provider."
9715
+ );
9716
+ for (const skill of skills) {
9717
+ console.log(`Skill ${skill.source}: ${skill.filePath}`);
9718
+ const args = nativeMode ? ["add-skill", skill.filePath, "--wait"] : ["add-resource", skill.filePath, "--to", skillResourceUri(skill), "--wait"];
9719
+ await maybeRun(options.dryRun === true, ov, withIdentity(config, args));
9720
+ }
9721
+ console.log(`Skill seed complete: ${skills.length} unique skill(s).`);
9722
+ }
9723
+ async function resolveRepoRoot(repoInput) {
9724
+ const inputPath = expandPath(repoInput);
9725
+ if (!await isDirectory(inputPath)) {
9726
+ throw new Error(`Repo path is not a directory: ${inputPath}`);
9481
9727
  }
9482
- const stagingDir = await (0, import_promises8.mkdtemp)((0, import_node_path9.join)((0, import_node_os4.tmpdir)(), "threadnote-share-"));
9483
- const tempPath = (0, import_node_path9.join)(stagingDir, "body.txt");
9728
+ return await gitValue(["rev-parse", "--show-toplevel"], inputPath) ?? inputPath;
9729
+ }
9730
+ async function projectIdentity(path) {
9731
+ const expanded = expandPath(path);
9484
9732
  try {
9485
- await (0, import_promises8.writeFile)(tempPath, content, { encoding: "utf8", mode: 384 });
9486
- await writeOvFileWithRetry(config, ov, uri, tempPath, initialMode);
9487
- } finally {
9488
- await (0, import_promises8.rm)(stagingDir, { force: true, recursive: true });
9733
+ return await (0, import_promises8.realpath)(expanded);
9734
+ } catch (_err) {
9735
+ return expanded;
9489
9736
  }
9490
9737
  }
9491
- async function writeOvFileWithRetry(config, ov, uri, fromFile, initialMode) {
9492
- const maxAttempts = 4;
9493
- const existedBeforeWrite = await vikingResourceExists2(ov, config, uri);
9494
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
9495
- const existsNow = attempt === 0 ? existedBeforeWrite : await vikingResourceExists2(ov, config, uri);
9496
- const ourWriteLanded = existsNow && !existedBeforeWrite;
9497
- const mode = attempt === 0 ? initialMode : ourWriteLanded ? "replace" : initialMode;
9498
- const args = withIdentity(config, [
9499
- "write",
9500
- uri,
9501
- "--from-file",
9502
- fromFile,
9503
- "--mode",
9504
- mode,
9505
- "--wait",
9506
- "--timeout",
9507
- "120"
9508
- ]);
9509
- console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
9510
- const result = await runCommand(ov, args, { allowFailure: true });
9511
- if (result.exitCode === 0) {
9512
- if (result.stdout.trim()) {
9513
- console.log(result.stdout.trim());
9514
- }
9515
- if (result.stderr.trim()) {
9516
- console.error(result.stderr.trim());
9517
- }
9518
- return;
9519
- }
9520
- if (isTransientOvFailure(result.stderr, result.stdout) && await vikingResourceExists2(ov, config, uri) && !existedBeforeWrite) {
9521
- console.log("OpenViking accepted the write but returned an error before the wait completed; draining the queue.");
9522
- await waitForOvQueue(ov, config);
9523
- return;
9524
- }
9525
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
9526
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
9738
+ function projectManifestForRepo(repoRoot, existingProjects) {
9739
+ const baseName = uriSegment((0, import_node_path9.basename)(repoRoot));
9740
+ const usedNames = new Set(existingProjects.map((project) => project.name));
9741
+ const usedUris = new Set(existingProjects.map((project) => project.uri));
9742
+ let name = baseName;
9743
+ let uri = `viking://resources/repos/${name}`;
9744
+ if (usedNames.has(name) || usedUris.has(uri)) {
9745
+ name = `${baseName}-${sha256(repoRoot).slice(0, 8)}`;
9746
+ uri = `viking://resources/repos/${name}`;
9747
+ }
9748
+ return {
9749
+ name,
9750
+ path: portablePath(repoRoot),
9751
+ seed: DEFAULT_SEED_PATTERNS,
9752
+ uri
9753
+ };
9754
+ }
9755
+ async function collectSeedCandidates(project, projectRoot, ignorePatterns) {
9756
+ const candidates = [];
9757
+ const seen = /* @__PURE__ */ new Set();
9758
+ for (const pattern of project.seed) {
9759
+ const files = await resolveProjectPattern(projectRoot, pattern);
9760
+ for (const filePath of files) {
9761
+ const relativePath = toPosixPath((0, import_node_path9.relative)(projectRoot, filePath));
9762
+ if (seen.has(relativePath) || matchesIgnore(relativePath, ignorePatterns)) {
9763
+ continue;
9764
+ }
9765
+ seen.add(relativePath);
9766
+ candidates.push({
9767
+ destinationUri: `${trimTrailingSlash(project.uri)}/${relativePath}`,
9768
+ filePath,
9769
+ projectName: project.name,
9770
+ relativePath
9771
+ });
9527
9772
  }
9528
- await sleep(1e3 * (attempt + 1));
9529
9773
  }
9774
+ return candidates;
9530
9775
  }
9531
- async function waitForOvQueue(ov, config) {
9532
- const result = await runCommand(ov, withIdentity(config, ["wait", "--timeout", "120"]), { allowFailure: true });
9533
- if (result.stdout.trim()) {
9534
- console.log(result.stdout.trim());
9776
+ async function resolveProjectPattern(projectRoot, pattern) {
9777
+ const normalizedPattern = toPosixPath(pattern);
9778
+ if (!hasGlob(normalizedPattern)) {
9779
+ const filePath = (0, import_node_path9.join)(projectRoot, normalizedPattern);
9780
+ return await isFile(filePath) ? [filePath] : [];
9535
9781
  }
9536
- if (result.stderr.trim()) {
9537
- console.error(result.stderr.trim());
9782
+ const globBase = getGlobBase(normalizedPattern);
9783
+ const basePath = (0, import_node_path9.join)(projectRoot, globBase);
9784
+ if (!await exists(basePath)) {
9785
+ return [];
9538
9786
  }
9787
+ const regex = globToRegExp(normalizedPattern);
9788
+ const files = await walkFiles(basePath);
9789
+ return files.filter((filePath) => regex.test(toPosixPath((0, import_node_path9.relative)(projectRoot, filePath))));
9539
9790
  }
9540
- function isTransientOvFailure(stderr, stdout) {
9541
- const output2 = `${stderr}
9542
- ${stdout}`.toLowerCase();
9543
- return output2.includes("resource is busy") || output2.includes("resource is being processed") || output2.includes("network error") || output2.includes("error sending request") || output2.includes("http request failed") || output2.includes("connection refused") || output2.includes("connection reset") || output2.includes("timed out");
9544
- }
9545
- async function ingestSingleFile(ov, config, uri, filePath, initialMode) {
9546
- const content = await (0, import_promises8.readFile)(filePath, "utf8");
9547
- await writeMemoryFile(config, ov, uri, content, initialMode, false);
9548
- }
9549
- async function removeWithRollback(config, ov, sourceUri, rollbackUri, worktree, dryRun, label) {
9550
- try {
9551
- await removeMemoryUri(config, ov, sourceUri, dryRun);
9552
- } catch (sourceErr) {
9553
- if (dryRun) {
9554
- throw sourceErr;
9555
- }
9556
- console.error(
9557
- `Source removal failed during ${label}; rolling back ${rollbackUri} so the system is back to the pre-${label} state.`
9791
+ async function prepareSeedFile(config, candidate, dryRun) {
9792
+ const content = await (0, import_promises8.readFile)(candidate.filePath, "utf8");
9793
+ const redactedContent = shouldRedactPath(candidate.relativePath) ? redactContent(candidate.relativePath, content) : content;
9794
+ const secretMatches = detectSecretMatches(redactedContent);
9795
+ if (secretMatches.length > 0) {
9796
+ console.log(
9797
+ `SKIP ${candidate.projectName}/${candidate.relativePath}: possible secret (${secretMatches.slice(0, MAX_SECRET_MATCHES_TO_PRINT).join(", ")})`
9558
9798
  );
9559
- try {
9560
- await removeMemoryUri(config, ov, rollbackUri, false);
9561
- } catch (rollbackErr) {
9562
- console.error(
9563
- `Rollback of ${rollbackUri} also failed. Manual cleanup needed via: threadnote forget ${rollbackUri}
9564
- Rollback error: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`
9565
- );
9566
- }
9567
- await bestEffortRemoveWorktreeFile(rollbackUri, worktree, label);
9568
- throw sourceErr;
9569
- }
9570
- }
9571
- async function bestEffortRemoveWorktreeFile(rollbackUri, worktree, label) {
9572
- if (label !== "publish") {
9573
- return;
9574
- }
9575
- const prefix = "viking://";
9576
- if (!rollbackUri.startsWith(prefix)) {
9577
- return;
9799
+ return void 0;
9578
9800
  }
9579
- const parts = rollbackUri.slice(prefix.length).split("/");
9580
- const sharedIndex = parts.indexOf("shared");
9581
- if (sharedIndex === -1 || sharedIndex + 2 >= parts.length) {
9582
- return;
9801
+ if (redactedContent === content) {
9802
+ return candidate.filePath;
9583
9803
  }
9584
- const relative3 = parts.slice(sharedIndex + 2).join("/");
9585
- if (!relative3) {
9586
- return;
9804
+ const redactedPath = (0, import_node_path9.join)(config.agentContextHome, "redacted", candidate.projectName, candidate.relativePath);
9805
+ if (dryRun) {
9806
+ console.log(`Would write redacted copy: ${redactedPath}`);
9807
+ return redactedPath;
9587
9808
  }
9588
- await (0, import_promises8.rm)((0, import_node_path9.join)(worktree, relative3), { force: true });
9809
+ await ensureDirectory((0, import_node_path9.dirname)(redactedPath), false);
9810
+ await (0, import_promises8.writeFile)(redactedPath, redactedContent, { encoding: "utf8", mode: 384 });
9811
+ await (0, import_promises8.chmod)(redactedPath, 384);
9812
+ return redactedPath;
9589
9813
  }
9590
- async function removeMemoryUri(config, ov, uri, dryRun) {
9591
- const args = withIdentity(config, ["rm", uri]);
9592
- if (dryRun) {
9593
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
9594
- return;
9814
+ async function collectSkillCandidates(config) {
9815
+ const sources = [
9816
+ { pattern: "~/.codex/skills/**/SKILL.md", source: "codex-global" },
9817
+ { pattern: "~/.codex/plugins/cache/**/skills/**/SKILL.md", source: "codex-plugin-cache" },
9818
+ { pattern: "~/.claude/skills/**/SKILL.md", source: "claude-global" }
9819
+ ];
9820
+ try {
9821
+ const manifest = await readSeedManifest(config.manifestPath);
9822
+ for (const project of manifest.projects) {
9823
+ sources.push({
9824
+ pattern: `${project.path}/.claude/skills/**/SKILL.md`,
9825
+ source: `repo-local:${project.name}`
9826
+ });
9827
+ }
9828
+ } catch (err) {
9829
+ console.log(`WARN cannot read manifest for repo-local skill discovery: ${errorMessage(err)}`);
9595
9830
  }
9596
- const maxAttempts = 4;
9597
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
9598
- const result = await runCommand(ov, args, { allowFailure: true });
9599
- if (result.exitCode === 0) {
9600
- if (result.stdout.trim()) {
9601
- console.log(result.stdout.trim());
9831
+ const seenHashes = /* @__PURE__ */ new Set();
9832
+ const skills = [];
9833
+ for (const source of sources) {
9834
+ const files = await resolveAbsolutePattern(expandPath(source.pattern));
9835
+ for (const filePath of files) {
9836
+ const content = await (0, import_promises8.readFile)(filePath, "utf8");
9837
+ const matches = detectSecretMatches(content);
9838
+ if (matches.length > 0) {
9839
+ console.log(`SKIP skill with possible secret: ${filePath}`);
9840
+ continue;
9602
9841
  }
9603
- return;
9604
- }
9605
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
9606
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
9842
+ const hash = sha256(content);
9843
+ if (seenHashes.has(hash)) {
9844
+ continue;
9845
+ }
9846
+ seenHashes.add(hash);
9847
+ skills.push({ filePath, hash, source: source.source });
9607
9848
  }
9608
- await sleep(1e3 * (attempt + 1));
9609
9849
  }
9850
+ return skills;
9610
9851
  }
9611
- async function vikingResourceExists2(ov, config, uri) {
9612
- const result = await runCommand(ov, withIdentity(config, ["stat", uri]), { allowFailure: true });
9613
- return result.exitCode === 0;
9614
- }
9615
- async function hasUncommittedChanges(worktree) {
9616
- const result = await runCommand("git", ["-C", worktree, "status", "--porcelain"], { allowFailure: true });
9617
- return result.stdout.trim().length > 0;
9618
- }
9619
- async function gitOutput(worktree, args, dryRun) {
9620
- if (dryRun) {
9621
- return void 0;
9622
- }
9623
- const result = await runCommand("git", ["-C", worktree, ...args], { allowFailure: true });
9624
- if (result.exitCode !== 0) {
9625
- return void 0;
9852
+ async function resolveAbsolutePattern(pattern) {
9853
+ const normalizedPattern = toPosixPath(pattern);
9854
+ if (!hasGlob(normalizedPattern)) {
9855
+ return await isFile(normalizedPattern) ? [normalizedPattern] : [];
9626
9856
  }
9627
- return result.stdout.trim();
9628
- }
9629
- async function listChangedFiles(worktree, beforeRev, afterRev) {
9630
- const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
9631
- allowFailure: true
9632
- });
9633
- if (result.exitCode !== 0) {
9857
+ const globBase = getGlobBase(normalizedPattern);
9858
+ const basePath = globBase.startsWith("/") ? globBase : `/${globBase}`;
9859
+ if (!await exists(basePath)) {
9634
9860
  return [];
9635
9861
  }
9636
- const entries = result.stdout.split("\0").filter((part) => part.length > 0);
9637
- const changes = [];
9638
- for (let index = 0; index < entries.length; ) {
9639
- const raw = entries[index];
9640
- const head = raw.slice(0, 1);
9641
- if (head === "R" || head === "C") {
9642
- const oldRel = entries[index + 1];
9643
- const newRel = entries[index + 2];
9644
- if (oldRel && newRel) {
9645
- changes.push({ path: (0, import_node_path9.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
9646
- changes.push({ path: (0, import_node_path9.join)(worktree, newRel), relativePath: newRel, status: "added" });
9862
+ const regex = globToRegExp(normalizedPattern);
9863
+ const files = await walkFiles(basePath);
9864
+ return files.filter((filePath) => regex.test(toPosixPath(filePath)));
9865
+ }
9866
+ function skillResourceUri(skill) {
9867
+ return `viking://resources/agent-skills/${uriSegment(skill.source)}/${uriSegment((0, import_node_path9.basename)((0, import_node_path9.dirname)(skill.filePath)))}-${skill.hash.slice(0, 12)}.md`;
9868
+ }
9869
+ async function loadIgnorePatterns() {
9870
+ const raw = await (0, import_promises8.readFile)((0, import_node_path9.join)(toolRoot(), ".threadnoteignore"), "utf8");
9871
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
9872
+ }
9873
+ function matchesIgnore(relativePath, patterns) {
9874
+ const path = toPosixPath(relativePath);
9875
+ for (const pattern of patterns) {
9876
+ const normalizedPattern = toPosixPath(pattern);
9877
+ if (normalizedPattern.endsWith("/")) {
9878
+ const directory = normalizedPattern.slice(0, -1);
9879
+ if (path === directory || path.includes(`/${directory}/`) || path.startsWith(`${directory}/`)) {
9880
+ return true;
9647
9881
  }
9648
- index += 3;
9649
9882
  continue;
9650
9883
  }
9651
- const rel = entries[index + 1];
9652
- if (rel) {
9653
- const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
9654
- changes.push({ path: (0, import_node_path9.join)(worktree, rel), relativePath: rel, status });
9884
+ if (globToRegExp(normalizedPattern).test(path) || globToRegExp(`**/${normalizedPattern}`).test(path)) {
9885
+ return true;
9655
9886
  }
9656
- index += 2;
9657
9887
  }
9658
- return changes;
9888
+ return false;
9659
9889
  }
9660
- async function applyChangesToOpenViking(config, team, changes) {
9661
- const ov = await openVikingCliForMode(false);
9662
- for (const change of changes) {
9663
- if (!change.relativePath.endsWith(".md")) {
9664
- continue;
9665
- }
9666
- const firstSegment = change.relativePath.split("/")[0];
9667
- if (!SHAREABLE_MEMORY_KIND_DIRS.includes(firstSegment)) {
9668
- continue;
9669
- }
9670
- const uri = workfileToVikingUri(config, team, change.path);
9671
- if (change.status === "removed") {
9672
- await removeMemoryUri(config, ov, uri, false);
9673
- continue;
9674
- }
9675
- if (!await isFile(change.path)) {
9676
- continue;
9890
+ function shouldRedactPath(relativePath) {
9891
+ const path = toPosixPath(relativePath);
9892
+ return path.endsWith(".mcp.json") || path.endsWith("config.toml") || path.includes("/settings") || path.includes("/settings.local");
9893
+ }
9894
+ function redactContent(relativePath, content) {
9895
+ if (relativePath.endsWith(".json")) {
9896
+ try {
9897
+ const parsed = JSON.parse(content);
9898
+ return `${JSON.stringify(redactJsonValue(parsed), null, 2)}
9899
+ `;
9900
+ } catch (_err) {
9901
+ return redactText(content);
9677
9902
  }
9678
- const ovHasResource = await vikingResourceExists2(ov, config, uri);
9679
- if (ovHasResource) {
9680
- const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
9681
- console.warn(`share sync: ${uri}: ${reason}.`);
9903
+ }
9904
+ return redactText(content);
9905
+ }
9906
+ function redactJsonValue(value) {
9907
+ if (Array.isArray(value)) {
9908
+ return value.map((item) => redactJsonValue(item));
9909
+ }
9910
+ if (!isJsonObject(value)) {
9911
+ return value;
9912
+ }
9913
+ const output2 = {};
9914
+ for (const [key, nestedValue] of Object.entries(value)) {
9915
+ output2[key] = isSensitiveKey(key) ? "[REDACTED]" : redactJsonValue(nestedValue);
9916
+ }
9917
+ return output2;
9918
+ }
9919
+ function isSensitiveKey(key) {
9920
+ return /token|secret|password|credential|authorization|api[_-]?key|client[_-]?secret|bearer/i.test(key);
9921
+ }
9922
+ function detectSecretMatches(content) {
9923
+ const detectors = [
9924
+ { label: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
9925
+ { label: "openai-key", regex: /sk-[A-Za-z0-9_-]{24,}/ },
9926
+ { label: "github-token", regex: /gh[pousr]_[A-Za-z0-9_]{24,}/ },
9927
+ { label: "slack-token", regex: /xox[abprs]-[A-Za-z0-9-]{24,}/ },
9928
+ { label: "bearer-token", regex: /Bearer\s+[A-Za-z0-9._~+/=-]{24,}/i },
9929
+ { label: "aws-access-key", regex: /AKIA[0-9A-Z]{16}/ }
9930
+ ];
9931
+ const matches = [];
9932
+ for (const detector of detectors) {
9933
+ if (detector.regex.test(content)) {
9934
+ matches.push(detector.label);
9682
9935
  }
9683
- await ensureSharedDirectoryChain(config, ov, uri, false);
9684
- const writeMode = ovHasResource ? "replace" : "create";
9685
- await ingestSingleFile(ov, config, uri, change.path, writeMode);
9686
9936
  }
9937
+ return matches;
9687
9938
  }
9688
9939
 
9689
9940
  // src/lifecycle.ts