diff --git a/config/seed-manifest.example.yaml b/config/seed-manifest.example.yaml index 85b9793..3e1ecd6 100644 --- a/config/seed-manifest.example.yaml +++ b/config/seed-manifest.example.yaml @@ -13,3 +13,12 @@ projects: - .claude/commands/*.md - .claude/skills/**/SKILL.md - docs/**/*.md + +# Optional: worksets group related projects into one recall working set. +# `threadnote recall --workset ` (or a query mentioning the name) expands +# recall across every member's durable + seeded scope. Unknown member names are +# ignored. Inspect with `threadnote workset list` / `threadnote workset show`. +# worksets: +# - name: example-stack +# description: service plus its client +# projects: [example-service, example-client] diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..d6b4dd3 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,181 @@ +import {readFile} from 'node:fs/promises'; +import {join} from 'node:path'; + +export interface DependencyFacts { + readonly dependencies: readonly string[]; + readonly ecosystems: readonly string[]; + readonly manifestFiles: readonly string[]; + readonly publishedName?: string; +} + +export interface GraphEdge { + readonly dependency: string; + readonly project: string; +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch { + return undefined; + } +} + +function parsePackageJson( + content: string, +): {readonly dependencies: readonly string[]; readonly publishedName?: string} | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return undefined; + } + const object = parsed as Record; + const dependencies = new Set(); + for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const section = object[key]; + if (section && typeof section === 'object' && !Array.isArray(section)) { + for (const name of Object.keys(section as Record)) { + dependencies.add(name); + } + } + } + return {dependencies: [...dependencies], publishedName: typeof object.name === 'string' ? object.name : undefined}; +} + +function parseGoMod(content: string): {readonly dependencies: readonly string[]; readonly publishedName?: string} { + const dependencies = new Set(); + let publishedName: string | undefined; + let inRequireBlock = false; + for (const rawLine of content.split('\n')) { + const line = rawLine.replace(/\/\/.*$/, '').trim(); + if (!line) { + continue; + } + const moduleMatch = /^module\s+(\S+)/.exec(line); + if (moduleMatch) { + publishedName = moduleMatch[1]; + continue; + } + if (/^require\s*\($/.test(line)) { + inRequireBlock = true; + continue; + } + if (inRequireBlock) { + if (line === ')') { + inRequireBlock = false; + continue; + } + const blockMatch = /^(\S+)\s+v\S+/.exec(line); + if (blockMatch) { + dependencies.add(blockMatch[1]); + } + continue; + } + const singleMatch = /^require\s+(\S+)\s+v\S+/.exec(line); + if (singleMatch) { + dependencies.add(singleMatch[1]); + } + } + return {dependencies: [...dependencies], publishedName}; +} + +/** + * Extracts declarative dependency facts from a project's root manifest files. + * Deterministic formats only (npm package.json + go.mod) so we never emit wrong + * edges; unparseable files are ignored. No AST, lockfiles, or API analysis. + */ +export async function extractDependencyFacts(projectRoot: string): Promise { + const dependencies = new Set(); + const ecosystems: string[] = []; + const manifestFiles: string[] = []; + let publishedName: string | undefined; + + const packageJson = await readIfExists(join(projectRoot, 'package.json')); + if (packageJson !== undefined) { + const parsed = parsePackageJson(packageJson); + if (parsed) { + manifestFiles.push('package.json'); + ecosystems.push('npm'); + publishedName = publishedName ?? parsed.publishedName; + for (const dependency of parsed.dependencies) { + dependencies.add(dependency); + } + } + } + + const goMod = await readIfExists(join(projectRoot, 'go.mod')); + if (goMod !== undefined) { + const parsed = parseGoMod(goMod); + manifestFiles.push('go.mod'); + ecosystems.push('go'); + publishedName = publishedName ?? parsed.publishedName; + for (const dependency of parsed.dependencies) { + dependencies.add(dependency); + } + } + + return {dependencies: [...dependencies].sort(), ecosystems, manifestFiles, publishedName}; +} + +/** + * Renders a project's dependency facts as a plain-Markdown resource document. + * In-workspace dependencies become `[[project]]` wiki-links so recall can bridge + * repos; everything else is counted. Emitted verbatim as a seeded resource — + * NOT a memory, so it carries no MEMORY/kind header. + */ +export function buildGraphDocument(params: { + readonly externalCount: number; + readonly facts: DependencyFacts; + readonly internalEdges: readonly GraphEdge[]; + readonly projectName: string; +}): string { + const {externalCount, facts, internalEdges, projectName} = params; + const lines = [ + `# ${projectName} — dependency facts`, + '', + 'Auto-generated by `threadnote seed --graph` from declarative manifest files. A static snapshot of package metadata, not a live dependency graph.', + '', + `provides: ${facts.publishedName ?? '(none declared)'}`, + `ecosystems: ${facts.ecosystems.length > 0 ? facts.ecosystems.join(', ') : '(none detected)'}`, + '', + 'manifests:', + ...(facts.manifestFiles.length > 0 ? facts.manifestFiles.map(file => `- ${file}`) : ['- (none)']), + '', + 'depends_on (within this workspace):', + ...(internalEdges.length > 0 + ? internalEdges.map(edge => `- [[${edge.project}]] (via ${edge.dependency})`) + : ['- (no in-workspace dependencies detected)']), + '', + `external dependencies: ${externalCount} declared`, + ]; + return `${lines.join('\n')}\n`; +} + +/** + * Resolves a project's raw dependency names against the published-name → project + * map, splitting them into in-workspace edges (deduped by target project) and a + * count of external deps. Self-edges are dropped. + */ +export function resolveGraphEdges( + projectName: string, + dependencies: readonly string[], + projectByPublishedName: ReadonlyMap, +): {readonly externalCount: number; readonly internalEdges: readonly GraphEdge[]} { + const internalEdges: GraphEdge[] = []; + const seenTargets = new Set(); + let externalCount = 0; + for (const dependency of dependencies) { + const target = projectByPublishedName.get(dependency.toLowerCase()); + if (target && target !== projectName && !seenTargets.has(target)) { + seenTargets.add(target); + internalEdges.push({dependency, project: target}); + } else if (!target) { + externalCount += 1; + } + } + return {externalCount, internalEdges}; +} diff --git a/src/hooks.ts b/src/hooks.ts index 1fbb5c5..37ea709 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -10,6 +10,8 @@ import { } from './constants.js'; import {parseAgentClient} from './mcp.js'; import {runHandoff, runRecall} from './memory.js'; +import {applyScrubber} from './share.js'; +import {distillTrace} from './trace.js'; import type {AgentClient, HookRunnerOptions, HooksInstallOptions, JsonObject, RuntimeConfig} from './types.js'; import {checkForThreadnoteUpdate, spawnDetachedAutoUpdate} from './update-check.js'; import {expandPath, exists, isJsonObject, parseJsonConfigObject, resolveRepoName} from './utils.js'; @@ -193,16 +195,19 @@ export async function runPreCompactHook(config: RuntimeConfig, options: HookRunn // and the process still exits 0 — the worst-case is a missed snapshot. try { const project = (await resolveRepoName()) ?? 'general'; + const {sessionId, trace} = await captureTraceContext(); await runHandoff(config, { blockers: '- none recorded', dryRun: options.dryRun === true, nextStep: 'Continue from this auto-snapshot. A manual `threadnote handoff` will produce a richer write-up if you have more context.', project, + sessionId, sourceAgentClient: 'claude', task: 'Auto-snapshot captured at Claude PreCompact (deterministic safety net before context compaction).', tests: '- not recorded (auto-snapshot)', topic: HOOK_AUTO_PRECOMPACT_TOPIC, + trace, }); } catch (err: unknown) { process.stderr.write( @@ -235,6 +240,98 @@ export async function runSessionStartHook(config: RuntimeConfig, options: HookRu } } +interface TraceContext { + readonly sessionId?: string; + readonly trace?: string; +} + +interface HookPayload { + readonly sessionId?: string; + readonly transcriptPath?: string; +} + +/** + * Reads Claude's PreCompact stdin payload and distills the referenced + * transcript into a short, scrubbed trace. Entirely best-effort: any failure + * (no stdin, unreadable transcript, unstable format, secret blocker) yields an + * empty context so the pre-compact snapshot still writes its state-only + * handoff. Never throws. + */ +async function captureTraceContext(): Promise { + try { + const payload = await readHookPayload(); + if (!payload) { + return {}; + } + const rawTrace = payload.transcriptPath ? await distillTrace(payload.transcriptPath) : undefined; + return {sessionId: payload.sessionId, trace: rawTrace ? scrubTrace(rawTrace) : undefined}; + } catch { + return {}; + } +} + +/** Redacts soft leaks; drops the trace on a hard credential blocker. */ +function scrubTrace(trace: string): string | undefined { + const result = applyScrubber(trace, {redact: true}); + return result.blocker ? undefined : result.cleaned; +} + +async function readHookPayload(): Promise { + if (process.stdin.isTTY) { + return undefined; + } + const raw = await readStdinWithTimeout(1500, 512 * 1024); + if (!raw.trim()) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!isJsonObject(parsed)) { + return undefined; + } + return { + sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : undefined, + transcriptPath: typeof parsed.transcript_path === 'string' ? parsed.transcript_path : undefined, + }; +} + +function readStdinWithTimeout(timeoutMs: number, maxBytes: number): Promise { + return new Promise(resolve => { + const stdin = process.stdin; + let data = ''; + let settled = false; + const finish = (): void => { + if (settled) { + return; + } + settled = true; + stdin.off('data', onData); + stdin.off('end', finish); + stdin.off('error', finish); + clearTimeout(timer); + stdin.pause(); + resolve(data); + }; + const onData = (chunk: string): void => { + data += chunk; + if (data.length >= maxBytes) { + data = data.slice(0, maxBytes); + finish(); + } + }; + const timer = setTimeout(finish, timeoutMs); + stdin.setEncoding('utf8'); + stdin.on('data', onData); + stdin.on('end', finish); + stdin.on('error', finish); + stdin.resume(); + }); +} + async function emitUpdateBannerIfOutdated(config: RuntimeConfig): Promise { // Cheap, daily-cached check that nags users to upgrade. The check is wrapped // in try/catch so a flaky registry or unreachable network never breaks the diff --git a/src/manifest.ts b/src/manifest.ts index 00f2e97..6db4d8c 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,7 +1,7 @@ import {readFile} from 'node:fs/promises'; import yaml from 'js-yaml'; -import type {JsonObject, ProjectManifest, SeedManifest} from './types.js'; -import {isJsonObject} from './utils.js'; +import type {JsonObject, ProjectManifest, ResolvedWorkset, SeedManifest, WorksetManifest} from './types.js'; +import {escapeRegExp, isJsonObject} from './utils.js'; export function uriSegment(value: string): string { const normalized = value @@ -27,6 +27,59 @@ export async function inferProjectFromQuery(manifestPath: string, query: string) } } +/** + * Resolves a workset's member names to their `ProjectManifest` entries, dropping + * names that do not match a known project. A workset is a named set of manifest + * projects that recall expands into one multi-repo working set. + */ +function resolveWorksetProjects(manifest: SeedManifest, workset: WorksetManifest): ResolvedWorkset { + const byName = new Map(manifest.projects.map(project => [project.name.toLowerCase(), project])); + const projects = workset.projects + .map(name => byName.get(name.toLowerCase())) + .filter((project): project is ProjectManifest => project !== undefined); + return {name: workset.name, projects}; +} + +/** Looks up a workset by exact (case-insensitive) name; undefined when unknown or unreadable. */ +export async function resolveWorkset(manifestPath: string, worksetName: string): Promise { + try { + const manifest = await readSeedManifest(manifestPath); + const workset = manifest.worksets?.find(entry => entry.name.toLowerCase() === worksetName.toLowerCase()); + return workset ? resolveWorksetProjects(manifest, workset) : undefined; + } catch { + return undefined; + } +} + +/** Resolves an explicit workset name; throws when the manifest is readable but no such workset exists. */ +export async function requireWorkset(manifestPath: string, worksetName: string): Promise { + const manifest = await readSeedManifest(manifestPath); + const workset = manifest.worksets?.find(entry => entry.name.toLowerCase() === worksetName.toLowerCase()); + if (!workset) { + throw new Error(`No workset named "${worksetName}" in ${manifestPath}.`); + } + return resolveWorksetProjects(manifest, workset); +} + +/** Returns the workset whose name appears as a token in `query`, or undefined. */ +export async function inferWorksetFromQuery(manifestPath: string, query: string): Promise { + try { + const manifest = await readSeedManifest(manifestPath); + if (!manifest.worksets || manifest.worksets.length === 0) { + return undefined; + } + const workset = manifest.worksets.find(entry => containsNameToken(query, entry.name)); + return workset ? resolveWorksetProjects(manifest, workset) : undefined; + } catch { + return undefined; + } +} + +function containsNameToken(query: string, name: string): boolean { + const escaped = escapeRegExp(name.toLowerCase()); + return new RegExp(`(^|[^a-z0-9])${escaped}($|[^a-z0-9])`).test(query.toLowerCase()); +} + export async function readSeedManifest(path: string): Promise { const raw = await readFile(path, 'utf8'); const loaded = yaml.load(raw); @@ -59,7 +112,35 @@ export async function readSeedManifest(path: string): Promise { uri: readString(loaded.future_monorepo, 'uri'), }; } - return {futureMonorepo, projects, version}; + + let worksets: readonly WorksetManifest[] | undefined; + if (loaded.worksets !== undefined) { + if (!Array.isArray(loaded.worksets)) { + throw new Error(`Manifest worksets must be an array: ${path}`); + } + worksets = loaded.worksets.map(worksetValue => { + if (!isJsonObject(worksetValue)) { + throw new Error(`Manifest workset must be an object: ${path}`); + } + return { + description: readOptionalString(worksetValue, 'description'), + name: readString(worksetValue, 'name'), + projects: readStringArray(worksetValue, 'projects'), + }; + }); + } + return {futureMonorepo, projects, version, worksets}; +} + +function readOptionalString(object: JsonObject, key: string): string | undefined { + const value = object[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error(`Expected string for ${key}`); + } + return value; } function readString(object: JsonObject, key: string): string { diff --git a/src/mcp_server.ts b/src/mcp_server.ts index a08d986..31c7dff 100644 --- a/src/mcp_server.ts +++ b/src/mcp_server.ts @@ -10,9 +10,9 @@ import {join} from 'node:path'; import {z} from 'zod'; import {DEFAULT_ACCOUNT, DEFAULT_AGENT_ID, DEFAULT_HOST, DEFAULT_PORT} from './constants.js'; import {formatRecallIndexRepairMessages, repairStaleRecallIndex} from './index_repair.js'; -import {inferProjectFromQuery} from './manifest.js'; +import {inferProjectFromQuery, inferWorksetFromQuery, requireWorkset} from './manifest.js'; import {buildOnboardingGuide, gatherOnboardingContext} from './onboarding.js'; -import type {ProjectManifest} from './types.js'; +import type {ProjectManifest, ResolvedWorkset} from './types.js'; import { activePersonalMemoryUrisFromText, type ArchiveAction, @@ -21,6 +21,8 @@ import { formatCompactPlan, parseMemoryDocument, recallHygieneNudges, + referencedContextExcerpt, + referencedUrisFromRecords, type MemoryRecord, } from './memory_hygiene.js'; import { @@ -85,6 +87,7 @@ interface MemoryMetadata { readonly archivedFrom?: string; readonly kind: MemoryKind; readonly project?: string; + readonly references?: readonly string[]; readonly sourceAgentClient: string; readonly status: MemoryStatus; readonly supersedes?: string; @@ -122,6 +125,16 @@ type CheckedTextArray = readonly ok: false; }; +type CheckedOptionalTextArray = + | { + readonly ok: true; + readonly value: readonly string[] | undefined; + } + | { + readonly error: CallToolResult; + readonly ok: false; + }; + // Version this MCP server process started from, captured at startup. A later // `threadnote update` overwrites the package on disk, but this resident stdio // process keeps running the old code (clients don't respawn an MCP server on @@ -921,9 +934,15 @@ function registerSearchTool(server: McpServer, config: RuntimeConfig, name: stri .describe( 'Minimum relevance score 0-1 (default 0.5); lower it (toward 0) to broaden when a recall comes back empty', ), + workset: z + .string() + .optional() + .describe( + 'Optional named workset (a set of related repos from the seed manifest) to recall across as one working set', + ), }, }, - async ({callerCwd, includeArchived, nodeLimit, query, threshold, uri}) => { + async ({callerCwd, includeArchived, nodeLimit, query, threshold, uri, workset}) => { const checkedQuery = requiredText(query, name, 'query', {query: 'unity-ui-ccc latest handoff'}); if (!checkedQuery.ok) { return checkedQuery.error; @@ -940,6 +959,7 @@ function registerSearchTool(server: McpServer, config: RuntimeConfig, name: stri nodeLimit, includeArchived: includeArchived === true, threshold: threshold === undefined ? undefined : String(threshold), + workset: workset?.trim() || undefined, }), ); }, @@ -953,6 +973,7 @@ interface RecallToolParams { readonly pinnedUri: string | undefined; readonly query: string; readonly threshold: string | undefined; + readonly workset: string | undefined; } async function runRecallTool(config: RuntimeConfig, params: RecallToolParams): Promise { @@ -984,6 +1005,7 @@ async function runRecallTool(config: RuntimeConfig, params: RecallToolParams): P const project = params.pinnedUri ? undefined : await inferProjectFromQuery(config.manifestPath, projectQuery); const limitArgs = params.nodeLimit ? ['--node-limit', String(params.nodeLimit)] : []; const threshold = params.threshold ?? RECALL_SCORE_THRESHOLD; + const explicitWorkset = params.workset ? await requireWorkset(config.manifestPath, params.workset) : undefined; // Run the global base pass plus a seeded project pass, then merge into one // deduped ranked list (per document, chunk anchors stripped) so the seeded // pass only adds project docs the base missed. --level 2 keeps Level-2 @@ -1007,7 +1029,32 @@ async function runRecallTool(config: RuntimeConfig, params: RecallToolParams): P passes.push(seeded.hits); } + // Workset expansion (see src/memory.ts:runRecall): recall a named set of + // manifest repos as one working set. Skipped when a pinned URI scopes the + // search. The merge dedupes overlapping hits; scopes are deduped and capped. const sections: string[] = []; + const workset = params.pinnedUri + ? undefined + : explicitWorkset + ? explicitWorkset + : await inferWorksetFromQuery(config.manifestPath, projectQuery); + if (workset && workset.projects.length > 0) { + sections.push(`Workset scope: ${workset.name} (${workset.projects.map(member => member.name).join(', ')})`); + const alreadyScoped = new Set([params.pinnedUri, seededUri].filter((uri): uri is string => uri !== undefined)); + const worksetScopes = worksetScopeUris(config, workset) + .filter(uri => !alreadyScoped.has(uri)) + .slice(0, MAX_WORKSET_PASSES); + for (const scope of worksetScopes) { + const worksetPass = await recallSearchHits( + config, + ['search', query, '--uri', scope, ...limitArgs], + threshold, + params.includeArchived, + ); + passes.push(worksetPass.hits); + } + } + const exactMatches = await collectExactMemoryMatches(config, query, params.includeArchived, project); const {semanticSection, exactTail} = buildRecallSections(passes, exactMatches, params.nodeLimit ?? 12); if (semanticSection) { @@ -1023,6 +1070,10 @@ async function runRecallTool(config: RuntimeConfig, params: RecallToolParams): P if (exactTail) { sections.push(exactTail); } + const referencedContext = await referencedContextSection(config, sections.join('\n\n')); + if (referencedContext) { + sections.push(referencedContext); + } const hygieneHints = await recallHygieneHintsSection(config, sections.join('\n\n')); if (hygieneHints) { sections.push(hygieneHints); @@ -1081,6 +1132,44 @@ async function recallHygieneHintsSection(config: RuntimeConfig, recallText: stri return nudges.length > 0 ? ['Memory hygiene hints:', ...nudges.map(nudge => `- ${nudge}`)].join('\n') : undefined; } +const MAX_REFERENCED_CONTEXT = 5; +const REFERENCED_EXCERPT_LINES = 12; + +/** + * Resolves the one-way `references:` pointers carried by the personal memories + * recall just surfaced, reading each read-only from the local store and + * appending a short excerpt. Bounded to one hop and a small cap; missing + * references degrade to a labeled line and never fail recall. + */ +async function referencedContextSection(config: RuntimeConfig, recallText: string): Promise { + const surfacedUris = activePersonalMemoryUrisFromText(recallText, config.user); + if (surfacedUris.length === 0) { + return undefined; + } + const surfaced = await readMemoryRecordsByUri(config, surfacedUris); + const referenced = referencedUrisFromRecords(surfaced, recallText); + if (referenced.length === 0) { + return undefined; + } + const capped = referenced.slice(0, MAX_REFERENCED_CONTEXT); + const records = await readMemoryRecordsByUri(config, capped); + const byUri = new Map(records.map(record => [record.uri, record])); + const lines = ['Referenced read-only context (one-way pointers from surfaced memories):']; + for (const uri of capped) { + const record = byUri.get(uri); + if (record) { + lines.push(`- ${uri}`, referencedContextExcerpt(record.body, REFERENCED_EXCERPT_LINES)); + } else { + lines.push(`- ${uri} [reference unavailable locally]`); + } + } + if (referenced.length > capped.length) { + const omitted = referenced.length - capped.length; + lines.push(`- … ${omitted} more referenced ${omitted === 1 ? 'memory' : 'memories'} omitted`); + } + return lines.join('\n'); +} + async function collectExactMemoryMatches( config: RuntimeConfig, query: string, @@ -1187,6 +1276,12 @@ function registerStoreTool(server: McpServer, config: RuntimeConfig, name: strin .optional() .describe('Memory lifecycle kind; durable facts and handoffs are most common'), project: z.string().optional().describe('Project/repo namespace, for example threadnote or mobile-native'), + references: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe( + 'Optional viking:// URI(s) to record as one-way, read-only prior context for this memory. Recall surfaces a short excerpt of each. Stripped from shared copies on publish.', + ), replaceUri: z .string() .optional() @@ -1202,7 +1297,7 @@ function registerStoreTool(server: McpServer, config: RuntimeConfig, name: strin topic: z.string().optional().describe('Stable topic; active project/topic memories update one file'), }, }, - async ({kind, project, replaceUri, sourceAgentClient, status, text, topic}) => { + async ({kind, project, references, replaceUri, sourceAgentClient, status, text, topic}) => { const checkedText = requiredText(text, name, 'text', {text: 'Durable engineering note...'}); if (!checkedText.ok) { return checkedText.error; @@ -1211,9 +1306,14 @@ function registerStoreTool(server: McpServer, config: RuntimeConfig, name: strin if (!checkedReplaceUri.ok) { return checkedReplaceUri.error; } + const checkedReferences = optionalVikingUriList(references, name); + if (!checkedReferences.ok) { + return checkedReferences.error; + } const metadata: MemoryMetadata = { kind: kind ?? 'durable', project: normalizeOptionalMetadata(project), + references: checkedReferences.value, sourceAgentClient: sourceAgentClient ?? 'mcp', status: status ?? 'active', timestamp: new Date().toISOString(), @@ -1743,6 +1843,21 @@ function exactMemoryScopes( }); } +const MAX_WORKSET_PASSES = 12; + +/** Durable + seeded recall scopes for every member of a workset (see src/memory.ts:worksetScopeUris). */ +function worksetScopeUris(config: RuntimeConfig, workset: ResolvedWorkset): readonly string[] { + const scopes: string[] = []; + for (const member of workset.projects) { + scopes.push(`viking://user/${uriSegment(config.user)}/memories/durable/projects/${uriSegment(member.name)}`); + const seeded = trimTrailingSlash(member.uri); + if (seeded.startsWith('viking://')) { + scopes.push(seeded); + } + } + return [...new Set(scopes)]; +} + function formatMemoryDocument(title: 'MEMORY', metadata: MemoryMetadata, body: string): string { const header = [ title, @@ -1754,6 +1869,7 @@ function formatMemoryDocument(title: 'MEMORY', metadata: MemoryMetadata, body: s `timestamp: ${metadata.timestamp}`, metadata.supersedes ? `supersedes: ${metadata.supersedes}` : undefined, metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : undefined, + ...(metadata.references ?? []).map(uri => `references: ${uri}`), ].filter((line): line is string => line !== undefined); return [...header, '', body.trim()].join('\n'); } @@ -1823,6 +1939,27 @@ function optionalVikingUri(value: string | undefined, toolName: string): Checked }; } +function optionalVikingUriList( + value: readonly string[] | string | undefined, + toolName: string, +): CheckedOptionalTextArray { + const rawValues = Array.isArray(value) ? value : value === undefined ? [] : [value]; + const uris = rawValues.map(uri => uri.trim()).filter(Boolean); + if (uris.length === 0) { + return {ok: true, value: undefined}; + } + const invalid = uris.find(uri => !uri.startsWith('viking://')); + if (invalid) { + return { + error: argumentError( + `Threadnote MCP tool "${toolName}" needs viking:// URI values for "references". Received: ${invalid}`, + ), + ok: false, + }; + } + return {ok: true, value: [...new Set(uris)]}; +} + function requiredVikingUriList( value: readonly string[] | string | undefined, toolName: string, diff --git a/src/memory.ts b/src/memory.ts index 2fde99e..a00f742 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,6 +1,6 @@ import {chmod, readFile, readdir, rm, stat, writeFile} from 'node:fs/promises'; import {basename, join, sep} from 'node:path'; -import {inferProjectFromQuery, uriSegment} from './manifest.js'; +import {inferProjectFromQuery, inferWorksetFromQuery, requireWorkset, uriSegment} from './manifest.js'; import {formatRecallIndexRepairMessages, repairStaleRecallIndex} from './index_repair.js'; import { activePersonalMemoryUrisFromText, @@ -10,6 +10,8 @@ import { handoffTopicForBranch, parseMemoryDocument, recallHygieneNudges, + referencedContextExcerpt, + referencedUrisFromRecords, topicForRecord, type MemoryRecord, } from './memory_hygiene.js'; @@ -29,6 +31,7 @@ import type { ReadOptions, RecallOptions, RememberOptions, + ResolvedWorkset, RuntimeConfig, } from './types.js'; import { @@ -88,6 +91,7 @@ interface MemoryMetadata { readonly archivedFrom?: string; readonly kind: MemoryKind; readonly project?: string; + readonly references?: readonly string[]; readonly sourceAgentClient: string; readonly status: MemoryStatus; readonly supersedes?: string; @@ -315,6 +319,7 @@ export async function runRecall(config: RuntimeConfig, options: RecallOptions): options.uri ?? (options.inferScope === false ? undefined : await inferRecallUri(config, projectQuery)); const project = await inferProjectFromQuery(config.manifestPath, options.project ?? projectQuery); const nodeLimit = options.nodeLimit ? parsePositiveInteger(options.nodeLimit, 'node limit') : undefined; + const explicitWorkset = options.workset ? await requireWorkset(config.manifestPath, options.workset) : undefined; const searchArgs = (scopeUri: string | undefined): readonly string[] => [ 'search', query, @@ -346,6 +351,26 @@ export async function runRecall(config: RuntimeConfig, options: RecallOptions): passes.push(await recallSearchHits(config, ov, searchArgs(seededUri), {dryRun, includeArchived})); } + // Workset expansion: a named set of manifest projects recalled as one working + // set. Push a durable + seeded scope pass per member; the merge dedupes hits, + // and the scope list is deduped/capped so overlap only costs bounded searches. + const workset = + !options.uri && explicitWorkset + ? explicitWorkset + : !options.uri && options.inferScope !== false + ? await inferWorksetFromQuery(config.manifestPath, projectQuery) + : undefined; + if (workset && workset.projects.length > 0) { + console.log(`Workset scope: ${workset.name} (${workset.projects.map(member => member.name).join(', ')})`); + const alreadyScoped = new Set([inferredUri, seededUri].filter((uri): uri is string => uri !== undefined)); + const worksetScopes = worksetScopeUris(config, workset) + .filter(uri => !alreadyScoped.has(uri)) + .slice(0, MAX_WORKSET_PASSES); + for (const scope of worksetScopes) { + passes.push(await recallSearchHits(config, ov, searchArgs(scope), {dryRun, includeArchived})); + } + } + const recallOutputs: string[] = []; const exactMatches = await collectExactMemoryMatches(config, ov, query, {dryRun, includeArchived, project}); const {semanticSection, exactTail} = buildRecallSections(passes, exactMatches, nodeLimit ?? 12); @@ -357,9 +382,53 @@ export async function runRecall(config: RuntimeConfig, options: RecallOptions): console.log(`\n${exactTail}`); recallOutputs.push(exactTail); } + const referencedSection = await referencedContextSection(config, recallOutputs.join('\n')); + if (referencedSection) { + console.log(`\n${referencedSection}`); + recallOutputs.push(referencedSection); + } await printRecallHygieneNudges(config, recallOutputs.join('\n')); } +const MAX_REFERENCED_CONTEXT = 5; +const REFERENCED_EXCERPT_LINES = 12; + +/** + * Resolves the one-way `references:` pointers carried by the personal memories + * recall just surfaced, reading each referenced memory read-only from the local + * store and appending a short excerpt. Bounded to one hop and a small cap; + * missing references degrade to a labeled line and never fail recall. + */ +async function referencedContextSection(config: RuntimeConfig, recallOutput: string): Promise { + const surfacedUris = activePersonalMemoryUrisFromText(recallOutput, config.user); + if (surfacedUris.length === 0) { + return undefined; + } + const surfaced = await readMemoryRecordsByUri(config, surfacedUris); + const referenced = referencedUrisFromRecords(surfaced, recallOutput); + if (referenced.length === 0) { + return undefined; + } + const capped = referenced.slice(0, MAX_REFERENCED_CONTEXT); + const records = await readMemoryRecordsByUri(config, capped); + const byUri = new Map(records.map(record => [record.uri, record])); + const lines = ['Referenced read-only context (one-way pointers from surfaced memories):']; + for (const uri of capped) { + const record = byUri.get(uri); + if (record) { + lines.push(`- ${uri}`, referencedContextExcerpt(record.body, REFERENCED_EXCERPT_LINES)); + } else { + lines.push(`- ${uri} [reference unavailable locally]`); + } + } + if (referenced.length > capped.length) { + lines.push( + `- … ${referenced.length - capped.length} more referenced ${referenced.length - capped.length === 1 ? 'memory' : 'memories'} omitted`, + ); + } + return lines.join('\n'); +} + /** * Run one recall search pass with `--output json` and return parsed hits. * Falls back to a plain search (without --threshold/--level) on a non-zero @@ -1065,6 +1134,25 @@ function lifecycleMigrationUri(config: RuntimeConfig, metadata: MemoryMetadata, return `${memoryDirectoryUri(config, metadata)}/legacy-${hash.slice(0, 16)}.md`; } +const MAX_WORKSET_PASSES = 12; + +/** + * Durable + seeded recall scopes for every member of a workset, in member + * order. Callers dedupe against the already-scoped passes and cap the result; + * the recall merge dedupes any overlapping hits. + */ +function worksetScopeUris(config: RuntimeConfig, workset: ResolvedWorkset): readonly string[] { + const scopes: string[] = []; + for (const member of workset.projects) { + scopes.push(`viking://user/${uriSegment(config.user)}/memories/durable/projects/${uriSegment(member.name)}`); + const seeded = trimTrailingSlash(member.uri); + if (seeded.startsWith('viking://')) { + scopes.push(seeded); + } + } + return [...new Set(scopes)]; +} + function exactMemoryScopes( config: RuntimeConfig, includeArchived: boolean, @@ -1150,6 +1238,7 @@ function formatMemoryDocument(title: 'MEMORY' | 'HANDOFF', metadata: MemoryMetad `timestamp: ${metadata.timestamp}`, metadata.supersedes ? `supersedes: ${metadata.supersedes}` : undefined, metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : undefined, + ...(metadata.references ?? []).map(uri => `references: ${uri}`), ].filter((line): line is string => line !== undefined); return [...header, '', body.trim()].join('\n'); } @@ -1433,11 +1522,34 @@ function isResourceBusy(stderr: string, stdout: string): boolean { return output.includes('resource is busy') || output.includes('resource is being processed'); } +/** + * Validates and dedupes caller-supplied reference URIs so a handoff can record + * one-way, read-only pointers to other memories/sessions. Invalid URIs throw + * (loud failure) rather than silently dropping; returns undefined when empty so + * the `references:` header lines are omitted entirely. + */ +function normalizeReferenceUris(references: readonly string[] | undefined): readonly string[] | undefined { + if (!references || references.length === 0) { + return undefined; + } + const seen = new Set(); + for (const raw of references) { + const uri = raw.trim(); + if (!uri || seen.has(uri)) { + continue; + } + assertVikingUri(uri); + seen.add(uri); + } + return seen.size > 0 ? [...seen] : undefined; +} + async function buildHandoff( options: HandoffOptions, ): Promise<{readonly bodyText: string; readonly metadata: MemoryMetadata}> { const repoRoot = (await gitValue(['rev-parse', '--show-toplevel'])) ?? getInvocationCwd(); const branch = (await gitValue(['branch', '--show-current'], repoRoot)) ?? 'unknown'; + const commit = (await gitValue(['rev-parse', 'HEAD'], repoRoot)) ?? 'unknown'; const status = (await gitValue(['status', '--short'], repoRoot)) ?? ''; const diffStat = (await gitValue(['diff', '--stat', 'HEAD'], repoRoot)) ?? ''; const touchedFiles = await gitTouchedFiles(repoRoot); @@ -1446,16 +1558,27 @@ async function buildHandoff( const metadata: MemoryMetadata = { kind: 'handoff', project: normalizeOptionalMetadata(options.project) ?? repoName, + references: normalizeReferenceUris(options.references), sourceAgentClient: options.sourceAgentClient ?? 'codex', status: 'active', timestamp: new Date().toISOString(), topic: handoffTopicForBranch(topicBranch, {timestamped: options.timestamped, topic: options.topic}), }; + // Caller-supplied review-state snapshot (pr/issue/ci). Threadnote has no + // GitHub client, so these are captured strings paired with the exact commit, + // never a live status board. + const reviewState = [ + options.pr ? `pr: ${options.pr}` : undefined, + options.issue ? `issue: ${options.issue}` : undefined, + options.ci ? `ci: ${options.ci}` : undefined, + ].filter((line): line is string => line !== undefined); const bodyText = [ `repo: ${repoName}`, `repo_path: ${repoRoot}`, `branch: ${branch || 'unknown'}`, + `commit: ${commit}`, `task: ${options.task ?? 'unspecified'}`, + ...reviewState, '', 'files_touched:', formatBlock(touchedFiles, '- none'), @@ -1474,6 +1597,8 @@ async function buildHandoff( '', 'next_step:', options.nextStep ?? '- inspect the current repo state and continue from this handoff', + ...(options.sessionId ? ['', `session_id: ${options.sessionId}`] : []), + ...(options.trace ? ['', 'trace (auto-captured, heuristic):', options.trace] : []), ].join('\n'); return {bodyText, metadata}; } diff --git a/src/memory_hygiene.ts b/src/memory_hygiene.ts index bc6f586..2f85d07 100644 --- a/src/memory_hygiene.ts +++ b/src/memory_hygiene.ts @@ -8,6 +8,7 @@ export interface MemoryMetadata { readonly archivedFrom?: string; readonly kind: MemoryKind; readonly project?: string; + readonly references?: readonly string[]; readonly sourceAgentClient: string; readonly status: MemoryStatus; readonly supersedes?: string; @@ -107,6 +108,7 @@ export function parseMemoryDocument(uri: string, content: string): MemoryRecord archivedFrom: headerValue(header, 'archived_from'), kind, project: normalizeOptionalMetadata(headerValue(header, 'project') ?? headerValue(header, 'repo')), + references: headerValues(header, 'references'), sourceAgentClient: headerValue(header, 'source_agent_client') ?? 'unknown', status, supersedes: headerValue(header, 'supersedes'), @@ -333,6 +335,37 @@ export function recallHygieneNudges( return [...new Set(nudges)]; } +/** + * Collects one-way `references:` pointers off already-surfaced memory records, + * dropping any URI that recall already displayed so the referenced-context pass + * only adds prior context the caller has not already seen. Deduped, order + * preserved. + */ +export function referencedUrisFromRecords(records: readonly MemoryRecord[], recallOutput: string): readonly string[] { + const seen = new Set(); + const result: string[] = []; + for (const record of records) { + for (const uri of record.metadata.references ?? []) { + if (seen.has(uri) || recallOutput.includes(uri)) { + continue; + } + seen.add(uri); + result.push(uri); + } + } + return result; +} + +/** Renders a short, indented excerpt of a referenced memory body for recall. */ +export function referencedContextExcerpt(body: string, maxLines: number): string { + const lines = body + .split('\n') + .map(line => line.trimEnd()) + .filter(line => line.trim().length > 0) + .slice(0, maxLines); + return lines.map(line => ` ${line}`).join('\n'); +} + export function activePersonalMemoryUrisFromText(text: string, user: string): readonly string[] { const userSegment = uriSegment(user); const matches = text.matchAll(/viking:\/\/[^\s)]+/g); @@ -508,6 +541,7 @@ function formatMemoryDocument(title: 'MEMORY' | 'HANDOFF', metadata: MemoryMetad `timestamp: ${metadata.timestamp}`, metadata.supersedes ? `supersedes: ${metadata.supersedes}` : undefined, metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : undefined, + ...(metadata.references ?? []).map(uri => `references: ${uri}`), ].filter((line): line is string => line !== undefined); return [...header, '', body.trim()].join('\n'); } @@ -521,6 +555,16 @@ function headerValue(header: string, key: string): string | undefined { .trim(); } +function headerValues(header: string, key: string): readonly string[] | undefined { + const prefix = `${key}:`; + const values = header + .split('\n') + .filter(line => line.startsWith(prefix)) + .map(line => line.slice(prefix.length).trim()) + .filter(value => value.length > 0); + return values.length > 0 ? values : undefined; +} + function parseOptionalMemoryKind(value: string | undefined): MemoryKind | undefined { if (!value) { return undefined; diff --git a/src/seeding.ts b/src/seeding.ts index a13c27b..9035a7b 100644 --- a/src/seeding.ts +++ b/src/seeding.ts @@ -8,6 +8,7 @@ import { SEED_WATCH_INTERVAL_ENV, USER_MANIFEST_NAME, } from './constants.js'; +import {buildGraphDocument, type DependencyFacts, extractDependencyFacts, resolveGraphEdges} from './graph.js'; import {readSeedManifest, uriSegment} from './manifest.js'; import {withIdentity} from './runtime.js'; import type { @@ -15,6 +16,7 @@ import type { ProjectManifest, RuntimeConfig, SeedCandidate, + SeedManifest, SeedOptions, SkillCandidate, } from './types.js'; @@ -147,6 +149,89 @@ export async function runSeed(config: RuntimeConfig, options: SeedOptions): Prom console.log( `Seed complete: ${importedCount} candidate(s), ${unchangedCount} unchanged, ${skippedCount} skipped for safety.`, ); + + if (options.graph === true) { + await seedDependencyGraphs(config, ov, manifest, projects, options.dryRun === true); + } +} + +/** + * Seeds a per-project `.graph.md` dependency-facts resource. Facts are extracted + * from every manifest project (so cross-repo `[[project]]` edges resolve even + * under --only), then a document is rendered and seeded for each target + * project. Synthesized content is routed through the same secret scanner as + * every other seeded file before it can reach OpenViking. Stored as a plain + * resource, never a memory. + */ +export async function seedDependencyGraphs( + config: RuntimeConfig, + ov: string, + manifest: SeedManifest, + targetProjects: readonly ProjectManifest[], + dryRun: boolean, +): Promise { + const factsByProject = new Map(); + for (const project of manifest.projects) { + const projectRoot = expandPath(project.path); + if (!(await exists(projectRoot))) { + continue; + } + factsByProject.set(project.name, await extractDependencyFacts(projectRoot)); + } + const projectByPublishedName = new Map(); + for (const [name, facts] of factsByProject) { + if (facts.publishedName) { + projectByPublishedName.set(facts.publishedName.toLowerCase(), name); + } + } + + let written = 0; + let skipped = 0; + for (const project of targetProjects) { + const facts = factsByProject.get(project.name); + if (!facts || facts.manifestFiles.length === 0) { + continue; + } + const {externalCount, internalEdges} = resolveGraphEdges(project.name, facts.dependencies, projectByPublishedName); + const document = buildGraphDocument({externalCount, facts, internalEdges, projectName: project.name}); + const secretMatches = detectSecretMatches(document); + if (secretMatches.length > 0) { + skipped += 1; + console.log( + `SKIP ${project.name}/.graph.md: possible secret (${secretMatches + .slice(0, MAX_SECRET_MATCHES_TO_PRINT) + .join(', ')})`, + ); + continue; + } + const destinationUri = `${trimTrailingSlash(project.uri)}/.graph.md`; + if (dryRun) { + console.log(`Would seed dependency facts: ${destinationUri} (${internalEdges.length} in-workspace edge(s))`); + written += 1; + continue; + } + const graphPath = join(config.agentContextHome, 'graph', graphCacheFileName(project.name)); + await ensureDirectory(dirname(graphPath), false); + await writeFile(graphPath, document, {encoding: 'utf8', mode: 0o600}); + await chmod(graphPath, 0o600); + await maybeRun( + false, + ov, + withIdentity(config, [ + 'add-resource', + graphPath, + '--to', + destinationUri, + '--reason', + `Dependency facts for ${project.name}`, + '--wait', + ]), + ); + written += 1; + } + console.log( + `Dependency graph seed complete: ${written} .graph.md resource(s)${skipped > 0 ? `, ${skipped} skipped for safety` : ''}.`, + ); } function filterProjects( @@ -242,6 +327,13 @@ export async function runInitManifest(config: RuntimeConfig, options: InitManife uri: existingManifest.futureMonorepo.uri, }; } + if (existingManifest?.worksets) { + outputManifest.worksets = existingManifest.worksets.map(workset => ({ + name: workset.name, + ...(workset.description !== undefined ? {description: workset.description} : {}), + projects: [...workset.projects], + })); + } const output = yaml.dump(outputManifest, {lineWidth: 120, noRefs: true}); if (options.dryRun === true) { @@ -259,6 +351,39 @@ export async function runInitManifest(config: RuntimeConfig, options: InitManife console.log(' threadnote seed'); } +export async function runWorksetList(config: RuntimeConfig): Promise { + const manifest = await readSeedManifest(config.manifestPath); + const worksets = manifest.worksets ?? []; + if (worksets.length === 0) { + console.log( + 'No worksets defined. Add a top-level `worksets:` list to the seed manifest to group related projects.', + ); + return; + } + console.log(`Worksets (${worksets.length}):`); + for (const workset of worksets) { + const summary = workset.description ? ` — ${workset.description}` : ''; + console.log(`- ${workset.name} (${workset.projects.length} project(s))${summary}`); + } +} + +export async function runWorksetShow(config: RuntimeConfig, name: string): Promise { + const manifest = await readSeedManifest(config.manifestPath); + const workset = manifest.worksets?.find(entry => entry.name.toLowerCase() === name.toLowerCase()); + if (!workset) { + throw new Error(`No workset named "${name}" in ${config.manifestPath}.`); + } + console.log(`Workset: ${workset.name}`); + if (workset.description) { + console.log(workset.description); + } + console.log('Projects:'); + for (const memberName of workset.projects) { + const project = manifest.projects.find(entry => entry.name.toLowerCase() === memberName.toLowerCase()); + console.log(project ? `- ${project.name} (${project.uri})` : `- ${memberName} [not found in manifest projects]`); + } +} + export async function runSeedSkills(config: RuntimeConfig, options: SeedOptions): Promise { const ov = await openVikingCliForMode(options.dryRun === true); const catalogItems = await collectSkillCandidates(config); @@ -416,6 +541,10 @@ function seedResourceReason(candidate: SeedCandidate): string { return `Project guidance for ${candidate.projectName}: ${candidate.relativePath}`; } +function graphCacheFileName(projectName: string): string { + return `${uriSegment(projectName)}-${sha256(projectName).slice(0, 8)}.graph.md`; +} + function skillResourceReason(skill: SkillCandidate): string { return `${skill.kind === 'command' ? 'Agent command' : 'Agent skill'} catalog item from ${skill.source}: ${basename( skill.filePath, diff --git a/src/share.ts b/src/share.ts index 03c2dc4..3c75360 100644 --- a/src/share.ts +++ b/src/share.ts @@ -3355,7 +3355,7 @@ export function stripPersonalProvenance(content: string): string { } const cleaned: string[] = []; for (let index = 0; index < headerEnd; index += 1) { - if (/^(?:supersedes|archived_from):\s/.test(lines[index])) { + if (/^(?:supersedes|archived_from|references):\s/.test(lines[index])) { continue; } cleaned.push(lines[index]); diff --git a/src/threadnote.ts b/src/threadnote.ts index 901a1e1..65b89f1 100755 --- a/src/threadnote.ts +++ b/src/threadnote.ts @@ -61,7 +61,7 @@ import { runRecall, runRemember, } from './memory.js'; -import {runInitManifest, runSeed, runSeedSkills} from './seeding.js'; +import {runInitManifest, runSeed, runSeedSkills, runWorksetList, runWorksetShow} from './seeding.js'; import { runShareInit, runShareInstallArtifacts, @@ -229,6 +229,10 @@ async function main(): Promise { .description('Seed curated context from the manifest; never indexes whole repos by default') .option('--dry-run', 'Print files and ov commands without importing') .option('--force', 'Re-upload every candidate even if mtime+size match the recorded state') + .option( + '--graph', + 'Also seed a per-project .graph.md dependency-facts resource (package.json/go.mod), with [[project]] cross-repo edges', + ) .option('--manifest ', 'Manifest path for this seed run') .option( '--only ', @@ -361,10 +365,33 @@ async function main(): Promise { .option('--project ', 'Prioritize a project: add a scoped pass over its memories alongside the global search') .option('--threshold ', 'Minimum relevance score 0-1 (default 0.45); lower to broaden when recall is empty') .option('--uri ', 'Restrict search to a viking:// URI') + .option( + '--workset ', + 'Recall across a named seed-manifest workset (a set of related repos) as one working set', + ) .action(async (options: RecallOptions) => { await runRecall(getRuntimeConfig(program), options); }); + const workset = program + .command('workset') + .description('Inspect seed-manifest worksets (named sets of related repos recalled as one working set)'); + + workset + .command('list') + .description('List worksets defined in the seed manifest') + .action(async () => { + await runWorksetList(getRuntimeConfig(program)); + }); + + workset + .command('show') + .description('Show the member projects of a workset') + .argument('', 'Workset name') + .action(async (name: string) => { + await runWorksetShow(getRuntimeConfig(program), name); + }); + program .command('compact') .description('Plan or apply scoped memory hygiene for active personal memories') @@ -404,17 +431,26 @@ async function main(): Promise { .command('handoff') .description('Capture current repo state as a durable cross-agent handoff memory') .option('--blockers ', 'Known blockers') + .option('--ci ', 'Captured CI status snapshot (free text; not a live status board)') .option('--dry-run', 'Print handoff without storing') + .option('--issue ', 'Related issue reference (number or URL)') .option('--next-step ', 'Suggested next step') + .option('--pr ', 'Related pull request reference (number or URL)') .option('--project ', 'Project/repo namespace; defaults to current repo basename') + .option( + '--reference ', + 'viking:// memory to record as one-way read-only prior context; repeat for multiple', + collectOption, + [], + ) .option('--replace ', 'Supersede an existing viking:// memory after the new handoff is stored') .option('--source-agent-client ', 'codex, claude, cursor, copilot, or another client name', 'codex') .option('--task ', 'Current task summary') .option('--tests ', 'Tests or checks run') .option('--timestamped', 'Store a historical timestamped handoff instead of updating the current branch handoff') .option('--topic ', 'Stable topic name; active handoffs with the same project/topic update one file') - .action(async (options: HandoffOptions) => { - await runHandoff(getRuntimeConfig(program), options); + .action(async (options: HandoffOptions & {readonly reference?: readonly string[]}) => { + await runHandoff(getRuntimeConfig(program), {...options, references: options.reference}); }); program diff --git a/src/trace.ts b/src/trace.ts new file mode 100644 index 0000000..e67bebc --- /dev/null +++ b/src/trace.ts @@ -0,0 +1,143 @@ +import {open, readFile, stat} from 'node:fs/promises'; +import {applyScrubber} from './share.js'; + +const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024; +const MAX_INTENTS = 5; +const MAX_INTENT_CHARS = 160; +const MAX_TOOLS = 12; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function extractText(content: unknown): string | undefined { + if (typeof content === 'string') { + return content.trim() || undefined; + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (typeof part === 'string') { + parts.push(part); + } else if (isRecord(part) && part.type === 'text' && typeof part.text === 'string') { + parts.push(part.text); + } + } + const joined = parts.join(' ').trim(); + return joined || undefined; +} + +function extractToolNames(content: unknown): readonly string[] { + if (!Array.isArray(content)) { + return []; + } + const names: string[] = []; + for (const part of content) { + if (isRecord(part) && part.type === 'tool_use' && typeof part.name === 'string') { + names.push(part.name); + } + } + return names; +} + +function truncate(value: string, max: number): string { + const oneLine = value.replace(/\s+/g, ' ').trim(); + return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine; +} + +/** + * Best-effort, heuristic distillation of an agent transcript (Claude Code + * JSONL) into a short summary: event count, tools used, and recent user + * intents. Returns undefined on any read/parse failure or empty transcript so + * the caller can fall back to a state-only handoff. The transcript format is + * agent-specific and unstable, so every field is defensive. Callers MUST scrub + * the result before persisting — user intents can contain secrets. + */ +export async function distillTrace(transcriptPath: string): Promise { + const raw = await readTranscriptTail(transcriptPath); + if (raw === undefined) { + return undefined; + } + if (!raw.trim()) { + return undefined; + } + let events = 0; + const tools = new Set(); + const intents: string[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (!isRecord(parsed)) { + continue; + } + events += 1; + const message = isRecord(parsed.message) ? parsed.message : parsed; + const role = + typeof message.role === 'string' ? message.role : typeof parsed.type === 'string' ? parsed.type : undefined; + if (role === 'user') { + const text = extractText(message.content); + if (text) { + const scrubbed = applyScrubber(text, {redact: true}); + if (scrubbed.blocker) { + return undefined; + } + intents.push(truncate(scrubbed.cleaned, MAX_INTENT_CHARS)); + } + } else if (role === 'assistant') { + for (const name of extractToolNames(message.content)) { + tools.add(name); + } + } + } + if (events === 0) { + return undefined; + } + const lines = [`- ${events} transcript events`]; + if (tools.size > 0) { + lines.push(`- tools used: ${[...tools].slice(0, MAX_TOOLS).join(', ')}`); + } + const recentIntents = intents.slice(-MAX_INTENTS); + if (recentIntents.length > 0) { + lines.push('- recent intents:'); + for (const intent of recentIntents) { + lines.push(` - ${intent}`); + } + } + return lines.join('\n'); +} + +async function readTranscriptTail(transcriptPath: string): Promise { + try { + const {size} = await stat(transcriptPath); + if (size === 0) { + return undefined; + } + if (size <= MAX_TRANSCRIPT_BYTES) { + return await readFile(transcriptPath, 'utf8'); + } + const start = size - MAX_TRANSCRIPT_BYTES; + const buffer = Buffer.alloc(MAX_TRANSCRIPT_BYTES); + const file = await open(transcriptPath, 'r'); + try { + const {bytesRead} = await file.read(buffer, 0, MAX_TRANSCRIPT_BYTES, start); + return buffer + .subarray(0, bytesRead) + .toString('utf8') + .replace(/^[^\n]*(?:\n|$)/, ''); + } finally { + await file.close(); + } + } catch { + return undefined; + } +} diff --git a/src/types.ts b/src/types.ts index 9d1bb3b..bf32244 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,17 @@ export interface ProjectManifest { readonly uri: string; } +export interface WorksetManifest { + readonly description?: string; + readonly name: string; + readonly projects: readonly string[]; +} + +export interface ResolvedWorkset { + readonly name: string; + readonly projects: readonly ProjectManifest[]; +} + export interface SeedManifest { readonly futureMonorepo?: { readonly pathCandidates: readonly string[]; @@ -31,6 +42,7 @@ export interface SeedManifest { }; readonly projects: readonly ProjectManifest[]; readonly version: number; + readonly worksets?: readonly WorksetManifest[]; } export interface CommandResult { @@ -145,6 +157,7 @@ export interface StartOptions { export interface SeedOptions { readonly dryRun?: boolean; readonly force?: boolean; + readonly graph?: boolean; readonly manifest?: string; readonly native?: boolean; readonly only?: readonly string[]; @@ -201,6 +214,7 @@ export interface RecallOptions { readonly query: string; readonly threshold?: string; readonly uri?: string; + readonly workset?: string; } export interface ReadOptions { @@ -217,15 +231,21 @@ export interface ListOptions { export interface HandoffOptions { readonly blockers?: string; + readonly ci?: string; readonly dryRun?: boolean; + readonly issue?: string; readonly nextStep?: string; + readonly pr?: string; readonly project?: string; + readonly references?: readonly string[]; readonly replace?: string; + readonly sessionId?: string; readonly sourceAgentClient?: string; readonly task?: string; readonly tests?: string; readonly timestamped?: boolean; readonly topic?: string; + readonly trace?: string; } export interface ArchiveOptions { diff --git a/src/utils.ts b/src/utils.ts index 5dde26d..3d21cff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -106,7 +106,7 @@ export function hasGlob(path: string): boolean { } export function escapeRegExp(value: string): string { - return value.replace(/[\\^$+?.()|[\]{}]/g, '\\$&'); + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export async function requiredOpenVikingCli(): Promise { diff --git a/test/unit/graph.test.ts b/test/unit/graph.test.ts new file mode 100644 index 0000000..0359a56 --- /dev/null +++ b/test/unit/graph.test.ts @@ -0,0 +1,96 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {buildGraphDocument, extractDependencyFacts, resolveGraphEdges} from '../../src/graph.js'; + +describe('extractDependencyFacts', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'tn-graph-')); + }); + + afterEach(async () => { + await rm(dir, {recursive: true, force: true}); + }); + + it('parses package.json name and all dependency sections', async () => { + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ + name: '@acme/web-app', + dependencies: {'@acme/design-system': '^1.0.0', react: '^18.0.0'}, + devDependencies: {vitest: '^4.0.0'}, + }), + 'utf8', + ); + const facts = await extractDependencyFacts(dir); + expect(facts.publishedName).toBe('@acme/web-app'); + expect(facts.ecosystems).toEqual(['npm']); + expect(facts.manifestFiles).toEqual(['package.json']); + expect(facts.dependencies).toEqual(['@acme/design-system', 'react', 'vitest']); + }); + + it('parses go.mod module and require block', async () => { + await writeFile( + join(dir, 'go.mod'), + [ + 'module github.com/acme/service', + '', + 'go 1.22', + '', + 'require (', + '\tgithub.com/acme/lib v1.2.3', + '\tgithub.com/pkg/errors v0.9.1 // indirect', + ')', + '', + ].join('\n'), + 'utf8', + ); + const facts = await extractDependencyFacts(dir); + expect(facts.publishedName).toBe('github.com/acme/service'); + expect(facts.ecosystems).toEqual(['go']); + expect(facts.dependencies).toEqual(['github.com/acme/lib', 'github.com/pkg/errors']); + }); + + it('returns empty facts when no manifests exist and ignores malformed package.json', async () => { + expect(await extractDependencyFacts(dir)).toEqual({dependencies: [], ecosystems: [], manifestFiles: []}); + await writeFile(join(dir, 'package.json'), '{not valid json', 'utf8'); + const facts = await extractDependencyFacts(dir); + expect(facts.manifestFiles).toEqual([]); + }); +}); + +describe('resolveGraphEdges', () => { + it('links in-workspace deps by project, counts the rest, and drops self/dupes', () => { + const projectByPublishedName = new Map([ + ['@acme/design-system', 'design-system'], + ['@acme/web-app', 'web-app'], + ]); + const {externalCount, internalEdges} = resolveGraphEdges( + 'web-app', + ['@acme/design-system', '@acme/design-system', '@acme/web-app', 'react'], + projectByPublishedName, + ); + expect(internalEdges).toEqual([{dependency: '@acme/design-system', project: 'design-system'}]); + expect(externalCount).toBe(1); + }); +}); + +describe('buildGraphDocument', () => { + it('renders plain markdown with [[project]] edges and no MEMORY header', () => { + const doc = buildGraphDocument({ + externalCount: 3, + facts: {dependencies: [], ecosystems: ['npm'], manifestFiles: ['package.json'], publishedName: '@acme/web-app'}, + internalEdges: [{dependency: '@acme/design-system', project: 'design-system'}], + projectName: 'web-app', + }); + expect(doc.startsWith('# web-app — dependency facts')).toBe(true); + expect(doc).not.toContain('MEMORY'); + expect(doc).not.toContain('kind:'); + expect(doc).toContain('provides: @acme/web-app'); + expect(doc).toContain('- [[design-system]] (via @acme/design-system)'); + expect(doc).toContain('external dependencies: 3 declared'); + }); +}); diff --git a/test/unit/manifest.worksets.test.ts b/test/unit/manifest.worksets.test.ts new file mode 100644 index 0000000..60ef6a3 --- /dev/null +++ b/test/unit/manifest.worksets.test.ts @@ -0,0 +1,90 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {inferWorksetFromQuery, readSeedManifest, resolveWorkset} from '../../src/manifest.js'; + +const MANIFEST = ` +version: 1 +projects: + - name: web-app + path: ~/src/web-app + uri: viking://resources/repos/web-app + seed: [README.md] + - name: design-system + path: ~/src/design-system + uri: viking://resources/repos/design-system + seed: [README.md] +worksets: + - name: storefront + description: web app plus its design system + projects: [web-app, design-system, missing-repo] +`; + +describe('seed manifest worksets', () => { + let dir: string; + let manifestPath: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'tn-workset-')); + manifestPath = join(dir, 'seed-manifest.yaml'); + await writeFile(manifestPath, MANIFEST, 'utf8'); + }); + + afterEach(async () => { + await rm(dir, {recursive: true, force: true}); + }); + + it('parses worksets from the manifest', async () => { + const manifest = await readSeedManifest(manifestPath); + expect(manifest.worksets).toEqual([ + { + description: 'web app plus its design system', + name: 'storefront', + projects: ['web-app', 'design-system', 'missing-repo'], + }, + ]); + }); + + it('resolves member names to project manifests and drops unknown names', async () => { + const resolved = await resolveWorkset(manifestPath, 'storefront'); + expect(resolved?.name).toBe('storefront'); + expect(resolved?.projects.map(project => project.name)).toEqual(['web-app', 'design-system']); + }); + + it('matches worksets case-insensitively and returns undefined for unknown names', async () => { + expect((await resolveWorkset(manifestPath, 'STOREFRONT'))?.name).toBe('storefront'); + expect(await resolveWorkset(manifestPath, 'nope')).toBeUndefined(); + }); + + it('infers a workset when its name appears as a query token', async () => { + const resolved = await inferWorksetFromQuery(manifestPath, 'continue the storefront rollout'); + expect(resolved?.name).toBe('storefront'); + expect(await inferWorksetFromQuery(manifestPath, 'an unrelated query')).toBeUndefined(); + }); + + it('does not infer a workset from a substring inside another token', async () => { + const apiManifest = ` +version: 1 +projects: + - name: web-app + path: ~/src/web-app + uri: viking://resources/repos/web-app + seed: [README.md] +worksets: + - name: api + projects: [web-app] +`; + const apiManifestPath = join(dir, 'api.yaml'); + await writeFile(apiManifestPath, apiManifest, 'utf8'); + + expect(await inferWorksetFromQuery(apiManifestPath, 'recap the current mapping')).toBeUndefined(); + expect((await inferWorksetFromQuery(apiManifestPath, 'review the api changes'))?.name).toBe('api'); + }); + + it('throws on a malformed worksets block', async () => { + const badPath = join(dir, 'bad.yaml'); + await writeFile(badPath, 'version: 1\nprojects: []\nworksets: [{description: no name}]\n', 'utf8'); + await expect(readSeedManifest(badPath)).rejects.toThrow(); + }); +}); diff --git a/test/unit/memory.recall.test.ts b/test/unit/memory.recall.test.ts index 0944464..1a8621f 100644 --- a/test/unit/memory.recall.test.ts +++ b/test/unit/memory.recall.test.ts @@ -1,3 +1,6 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {hasAgentSkillCatalogIntent, runRecall, stripAdvancedSearchFlags} from '../../src/memory.js'; import type {RuntimeConfig} from '../../src/types.js'; @@ -72,6 +75,123 @@ describe('runRecall index repair fallback', () => { expect(output).toContain('Would run: /ov search'); expect(output).toContain('availability check'); }); + + it('honors an explicit workset when inference is disabled', async () => { + const dir = await mkdtemp(join(tmpdir(), 'threadnote-recall-workset-')); + const manifestPath = join(dir, 'seed-manifest.yaml'); + await writeFile( + manifestPath, + [ + 'version: 1', + 'projects:', + ' - name: alpha', + ` path: ${dir}/alpha`, + ' uri: viking://resources/repos/alpha', + ' seed: []', + 'worksets:', + ' - name: platform', + ' projects: [alpha]', + '', + ].join('\n'), + 'utf8', + ); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await runRecall( + {...runtime, manifestPath}, + { + dryRun: true, + inferScope: false, + query: 'current status', + workset: 'platform', + }, + ); + } finally { + await rm(dir, {force: true, recursive: true}); + } + + const output = log.mock.calls.map(call => call.join(' ')).join('\n'); + expect(output).toContain('Workset scope: platform (alpha)'); + expect(output).toContain('viking://resources/repos/alpha'); + }); + + it('reports an unknown explicit workset instead of running unscoped', async () => { + const dir = await mkdtemp(join(tmpdir(), 'threadnote-recall-missing-workset-')); + const manifestPath = join(dir, 'seed-manifest.yaml'); + await writeFile( + manifestPath, + [ + 'version: 1', + 'projects:', + ' - name: alpha', + ` path: ${dir}/alpha`, + ' uri: viking://resources/repos/alpha', + ' seed: []', + 'worksets:', + ' - name: platform', + ' projects: [alpha]', + '', + ].join('\n'), + 'utf8', + ); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect( + runRecall( + {...runtime, manifestPath}, + { + dryRun: true, + query: 'current status', + workset: 'platfrom', + }, + ), + ).rejects.toThrow(`No workset named "platfrom" in ${manifestPath}.`); + } finally { + await rm(dir, {force: true, recursive: true}); + } + expect(log.mock.calls.map(call => call.join(' ')).join('\n')).not.toContain('/ov search'); + }); + + it('validates an explicit workset before a pinned uri search', async () => { + const dir = await mkdtemp(join(tmpdir(), 'threadnote-recall-pinned-workset-')); + const manifestPath = join(dir, 'seed-manifest.yaml'); + await writeFile( + manifestPath, + [ + 'version: 1', + 'projects:', + ' - name: alpha', + ` path: ${dir}/alpha`, + ' uri: viking://resources/repos/alpha', + ' seed: []', + 'worksets:', + ' - name: platform', + ' projects: [alpha]', + '', + ].join('\n'), + 'utf8', + ); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect( + runRecall( + {...runtime, manifestPath}, + { + dryRun: true, + query: 'current status', + uri: 'viking://resources/repos/alpha', + workset: 'platfrom', + }, + ), + ).rejects.toThrow(`No workset named "platfrom" in ${manifestPath}.`); + } finally { + await rm(dir, {force: true, recursive: true}); + } + expect(log.mock.calls.map(call => call.join(' ')).join('\n')).not.toContain('/ov search'); + }); }); describe('stripAdvancedSearchFlags', () => { diff --git a/test/unit/memory_hygiene.test.ts b/test/unit/memory_hygiene.test.ts index 0fa670c..77045d0 100644 --- a/test/unit/memory_hygiene.test.ts +++ b/test/unit/memory_hygiene.test.ts @@ -5,7 +5,10 @@ import { formatCompactPlan, handoffTopicForBranch, memoryContentWithHygieneSources, + parseMemoryDocument, recallHygieneNudges, + referencedContextExcerpt, + referencedUrisFromRecords, } from '../../src/memory_hygiene.js'; function record( @@ -260,3 +263,53 @@ describe('formatCompactPlan', () => { expect(formatCompactPlan(plan, {apply: false})).toContain('Archive old handoffs'); }); }); + +describe('references relation', () => { + it('parses multiple references: header lines into metadata.references', () => { + const content = [ + 'HANDOFF', + 'kind: handoff', + 'status: active', + 'project: threadnote', + 'topic: my-branch', + 'source_agent_client: codex', + 'timestamp: 2026-07-02T00:00:00.000Z', + 'references: viking://user/me/memories/durable/projects/threadnote/design.md', + 'references: viking://user/me/memories/handoffs/active/threadnote/prior.md', + '', + 'task: keep going', + ].join('\n'); + const parsed = parseMemoryDocument('viking://user/me/memories/handoffs/active/threadnote/my-branch.md', content); + expect(parsed?.metadata.references).toEqual([ + 'viking://user/me/memories/durable/projects/threadnote/design.md', + 'viking://user/me/memories/handoffs/active/threadnote/prior.md', + ]); + }); + + it('leaves references undefined when absent', () => { + const content = ['MEMORY', 'kind: durable', 'status: active', 'project: x', '', 'body'].join('\n'); + const parsed = parseMemoryDocument('viking://user/me/memories/durable/projects/x/a.md', content); + expect(parsed?.metadata.references).toBeUndefined(); + }); + + it('collects referenced uris off records, skipping ones already surfaced', () => { + const withRefs = record({ + uri: 'viking://user/me/memories/handoffs/active/threadnote/branch.md', + metadata: { + references: [ + 'viking://user/me/memories/durable/projects/threadnote/design.md', + 'viking://user/me/memories/durable/projects/threadnote/shown.md', + ], + }, + }); + const recallOutput = 'surfaced: viking://user/me/memories/durable/projects/threadnote/shown.md'; + expect(referencedUrisFromRecords([withRefs], recallOutput)).toEqual([ + 'viking://user/me/memories/durable/projects/threadnote/design.md', + ]); + }); + + it('renders a bounded, indented excerpt', () => { + const excerpt = referencedContextExcerpt(['line one', '', 'line two', 'line three'].join('\n'), 2); + expect(excerpt).toBe(' line one\n line two'); + }); +}); diff --git a/test/unit/seeding.test.ts b/test/unit/seeding.test.ts index 5b4bc0b..0f17a97 100644 --- a/test/unit/seeding.test.ts +++ b/test/unit/seeding.test.ts @@ -1,9 +1,16 @@ -import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, describe, expect, it} from 'vitest'; -import {parseSeedWatchIntervalMinutes, runSeedSkills, seedWatchArgs} from '../../src/seeding.js'; -import type {RuntimeConfig} from '../../src/types.js'; +import { + parseSeedWatchIntervalMinutes, + runInitManifest, + runSeedSkills, + seedDependencyGraphs, + seedWatchArgs, +} from '../../src/seeding.js'; +import {readSeedManifest} from '../../src/manifest.js'; +import type {RuntimeConfig, SeedManifest} from '../../src/types.js'; async function captureConsole(action: () => Promise): Promise { const lines: string[] = []; @@ -121,3 +128,107 @@ describe('seed-skills', () => { expect(output).toContain('Skill seed complete: 2 unique catalog item(s).'); }); }); + +describe('init-manifest', () => { + const homes: string[] = []; + + afterEach(async () => { + await Promise.all(homes.splice(0).map(home => rm(home, {force: true, recursive: true}))); + }); + + it('preserves worksets while adding a repo', async () => { + const contextHome = await mkdtemp(join(tmpdir(), 'threadnote-init-manifest-context-')); + const existingRepo = await mkdtemp(join(tmpdir(), 'threadnote-existing-repo-')); + const newRepo = await mkdtemp(join(tmpdir(), 'threadnote-new-repo-')); + homes.push(contextHome, existingRepo, newRepo); + const manifestPath = join(contextHome, 'seed-manifest.yaml'); + await writeFile( + manifestPath, + [ + 'version: 1', + 'projects:', + ' - name: existing-repo', + ` path: ${existingRepo}`, + ' uri: viking://resources/repos/existing-repo', + ' seed: [README.md]', + 'worksets:', + ' - name: platform', + ' description: existing grouped repos', + ' projects: [existing-repo, missing-repo]', + '', + ].join('\n'), + ); + + const config: RuntimeConfig = { + account: 'local', + agentContextHome: contextHome, + agentId: 'threadnote', + host: '127.0.0.1', + manifestPath, + openVikingVersion: '0.0.0', + port: 1933, + user: 'denys', + }; + + await captureConsole(() => runInitManifest(config, {path: manifestPath, repo: [newRepo]})); + + const manifest = await readSeedManifest(manifestPath); + expect(manifest.projects).toHaveLength(2); + expect(manifest.projects[0]?.name).toBe('existing-repo'); + expect(manifest.projects[1]?.path).toContain('threadnote-new-repo-'); + expect(manifest.worksets).toEqual([ + { + description: 'existing grouped repos', + name: 'platform', + projects: ['existing-repo', 'missing-repo'], + }, + ]); + }); +}); + +describe('seedDependencyGraphs', () => { + const homes: string[] = []; + + afterEach(async () => { + await Promise.all(homes.splice(0).map(home => rm(home, {force: true, recursive: true}))); + }); + + it('uses a safe cache filename for manifest project names', async () => { + const contextHome = await mkdtemp(join(tmpdir(), 'threadnote-graph-context-')); + const repo = await mkdtemp(join(tmpdir(), 'threadnote-graph-repo-')); + homes.push(contextHome, repo); + await writeFile(join(repo, 'package.json'), JSON.stringify({name: '@acme/pkg'}), 'utf8'); + const ov = join(contextHome, 'ov'); + await writeFile(ov, '#!/bin/sh\nexit 0\n', 'utf8'); + await chmod(ov, 0o700); + const manifest: SeedManifest = { + projects: [ + { + name: '../bad', + path: repo, + seed: [], + uri: 'viking://resources/repos/bad', + }, + ], + version: 1, + }; + const config: RuntimeConfig = { + account: 'local', + agentContextHome: contextHome, + agentId: 'threadnote', + host: '127.0.0.1', + manifestPath: join(contextHome, 'seed-manifest.yaml'), + openVikingVersion: '0.0.0', + port: 1933, + user: 'denys', + }; + + await captureConsole(() => seedDependencyGraphs(config, ov, manifest, manifest.projects, false)); + + const graphFiles = await readdir(join(contextHome, 'graph')); + expect(graphFiles).toHaveLength(1); + expect(graphFiles[0]).not.toContain('/'); + expect(await readFile(join(contextHome, 'graph', graphFiles[0]), 'utf8')).toContain('# ../bad — dependency facts'); + await expect(readFile(join(contextHome, 'bad.graph.md'), 'utf8')).rejects.toThrow(); + }); +}); diff --git a/test/unit/share.scrubber.test.ts b/test/unit/share.scrubber.test.ts index e8d544e..a2c7a8f 100644 --- a/test/unit/share.scrubber.test.ts +++ b/test/unit/share.scrubber.test.ts @@ -99,6 +99,21 @@ describe('stripPersonalProvenance', () => { expect(out).toContain('archived_from: also kept here.'); }); + it('removes references lines from the header block but not the body', () => { + const input = [ + 'MEMORY', + 'kind: durable', + 'project: foo', + 'references: viking://user/me/memories/durable/projects/foo/a.md', + 'references: viking://user/me/memories/handoffs/active/foo/b.md', + '', + 'Body mentioning references: should NOT be stripped.', + ].join('\n'); + const out = stripPersonalProvenance(input); + expect(out).not.toMatch(/^references:/m); + expect(out).toContain('Body mentioning references:'); + }); + it('leaves content unchanged when there is no header to strip', () => { const input = 'just a body\nwith no provenance'; expect(stripPersonalProvenance(input)).toBe(input); diff --git a/test/unit/trace.test.ts b/test/unit/trace.test.ts new file mode 100644 index 0000000..6a1ec0d --- /dev/null +++ b/test/unit/trace.test.ts @@ -0,0 +1,84 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {distillTrace} from '../../src/trace.js'; + +describe('distillTrace', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'tn-trace-')); + }); + + afterEach(async () => { + await rm(dir, {recursive: true, force: true}); + }); + + it('summarizes events, tools, and recent intents from a JSONL transcript', async () => { + const path = join(dir, 't.jsonl'); + const lines = [ + JSON.stringify({type: 'user', message: {role: 'user', content: 'Add the workset feature'}}), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + {type: 'text', text: 'ok'}, + {type: 'tool_use', name: 'Edit'}, + ], + }, + }), + JSON.stringify({type: 'assistant', message: {role: 'assistant', content: [{type: 'tool_use', name: 'Bash'}]}}), + 'not valid json', + JSON.stringify({type: 'user', message: {role: 'user', content: [{type: 'text', text: 'now run the tests'}]}}), + ].join('\n'); + await writeFile(path, lines, 'utf8'); + const summary = await distillTrace(path); + expect(summary).toContain('4 transcript events'); + expect(summary).toContain('tools used: Edit, Bash'); + expect(summary).toContain('recent intents:'); + expect(summary).toContain('Add the workset feature'); + expect(summary).toContain('now run the tests'); + }); + + it('returns undefined for a missing transcript', async () => { + expect(await distillTrace(join(dir, 'missing.jsonl'))).toBeUndefined(); + }); + + it('returns undefined when no line parses as an event', async () => { + const path = join(dir, 'garbage.jsonl'); + await writeFile(path, 'garbage\nmore garbage\n', 'utf8'); + expect(await distillTrace(path)).toBeUndefined(); + }); + + it('summarizes large transcripts from the capped tail', async () => { + const path = join(dir, 'large.jsonl'); + const prefix = `${JSON.stringify({type: 'user', message: {role: 'user', content: 'old intent'}})}\n${'x'.repeat( + 4 * 1024 * 1024, + )}`; + await writeFile( + path, + `${prefix}\n${JSON.stringify({type: 'user', message: {role: 'user', content: 'tail intent'}})}\n`, + 'utf8', + ); + + const summary = await distillTrace(path); + expect(summary).toContain('tail intent'); + expect(summary).not.toContain('old intent'); + }); + + it('drops traces when untruncated intent text contains a credential', async () => { + const path = join(dir, 'secret.jsonl'); + await writeFile( + path, + `${JSON.stringify({ + type: 'user', + message: {role: 'user', content: `${'x'.repeat(155)} sk-abcdefghijklmnopqr1234`}, + })}\n`, + 'utf8', + ); + + expect(await distillTrace(path)).toBeUndefined(); + }); +}); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index b37f74e..22221f1 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -337,6 +337,7 @@ describe('escapeRegExp', () => { expect(escapeRegExp('a.b')).toBe('a\\.b'); expect(escapeRegExp('(x)')).toBe('\\(x\\)'); expect(escapeRegExp('a|b')).toBe('a\\|b'); + expect(escapeRegExp('a*b')).toBe('a\\*b'); }); });