Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.4.1

- Repair stale automatic conversation links when the current project has
stronger path or Git evidence.
- Prevent broad parent repositories from claiming conversations whose recorded
Git remote belongs to another project.

## 0.4.0

- Rename the project and public interfaces to ThreadRelink.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "threadrelink-monorepo",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"description": "Path-independent discovery and recovery for local Codex conversations.",
"license": "MIT",
Expand Down
65 changes: 41 additions & 24 deletions packages/core/src/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,35 +33,47 @@ export async function matchThreadToProject(
context: MatchContext,
): Promise<MatchDecision> {
const { project, existingLink } = context;
if (existingLink) {
if (existingLink.projectId === project.id) {
return decision(
thread,
"linked",
project.id,
[
{
kind: "stored-link",
confidence: 1,
description: "Conversation was already linked to this ThreadRelink project.",
},
],
existingLink.relativeCwd,
);
}
if (existingLink?.projectId === project.id) {
return decision(
thread,
"linked",
project.id,
[
{
kind: "stored-link",
confidence: 1,
description: "Conversation was already linked to this ThreadRelink project.",
},
],
existingLink.relativeCwd,
);
}
if (existingLink?.linkedBy === "manual") {
return decision(thread, "unlinked", null, [
{
kind: "stored-link",
confidence: 1,
description: "Conversation is already linked to another ThreadRelink project.",
description:
"Conversation was manually linked to another ThreadRelink project.",
},
]);
}

const threadRemote = thread.gitInfo?.originUrl
? normalizeRemoteUrl(thread.gitInfo.originUrl)
: null;
const remoteMatches = Boolean(
threadRemote && project.remotes.includes(threadRemote),
);
const remoteConflicts = Boolean(
threadRemote
&& project.remotes.length > 0
&& !remoteMatches,
);
const matchingAlias = project.aliases.find((alias) =>
isPathInside(thread.cwd, alias.path)
);
if (matchingAlias) {
if (matchingAlias && !remoteConflicts) {
return decision(
thread,
"linked",
Expand All @@ -77,12 +89,6 @@ export async function matchThreadToProject(
);
}

const threadRemote = thread.gitInfo?.originUrl
? normalizeRemoteUrl(thread.gitInfo.originUrl)
: null;
const remoteMatches = Boolean(
threadRemote && project.remotes.includes(threadRemote),
);
const shaMatches = Boolean(
context.gitRoot
&& thread.gitInfo?.sha
Expand Down Expand Up @@ -130,5 +136,16 @@ export async function matchThreadToProject(
]);
}

if (existingLink) {
return decision(thread, "unlinked", null, [
{
kind: "stored-link",
confidence: 1,
description:
"Conversation has an automatic link to another ThreadRelink project and no stronger evidence was found.",
},
]);
}

return decision(thread, "unlinked", null, []);
}
70 changes: 70 additions & 0 deletions packages/core/test/matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { matchThreadToProject } from "../src/matcher.js";
import type {
ProjectRecord,
ThreadLink,
ThreadMetadata,
} from "../src/types.js";

Expand Down Expand Up @@ -45,6 +46,27 @@ function thread(overrides: Partial<ThreadMetadata> = {}): ThreadMetadata {
};
}

function link(overrides: Partial<ThreadLink> = {}): ThreadLink {
return {
provider: "codex",
threadId: "thread-1",
projectId: "project-old",
linkedBy: "automatic",
originalCwd: "/repo/ToolSpec",
relativeCwd: "",
evidence: [
{
kind: "path-alias",
confidence: 1,
description: "Legacy automatic path match.",
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}

describe("conversation matching", () => {
it("automatically links a known historical path alias", async () => {
const result = await matchThreadToProject(thread(), {
Expand Down Expand Up @@ -102,4 +124,52 @@ describe("conversation matching", () => {
expect(result.status).toBe("suggested");
expect(result.evidence[0]?.confidence).toBeLessThan(0.9);
});

it("repairs an automatic link to another project when a path alias matches", async () => {
const result = await matchThreadToProject(thread(), {
project,
existingLink: link(),
gitRoot: "/repo/FinSpec",
shaExists: async () => false,
});

expect(result.status).toBe("linked");
expect(result.projectId).toBe(project.id);
expect(result.evidence[0]?.kind).toBe("path-alias");
});

it("does not automatically replace a manual link to another project", async () => {
const result = await matchThreadToProject(thread(), {
project,
existingLink: link({ linkedBy: "manual" }),
gitRoot: "/repo/FinSpec",
shaExists: async () => false,
});

expect(result.status).toBe("unlinked");
expect(result.projectId).toBeNull();
expect(result.evidence[0]?.description).toContain("manually linked");
});

it("rejects a broad path alias when the recorded Git remote conflicts", async () => {
const result = await matchThreadToProject(
thread({
cwd: "/repo/ToolSpec/nested-project",
gitInfo: {
branch: "main",
originUrl: "https://github.com/ascendho/other-project.git",
sha: null,
},
}),
{
project,
existingLink: null,
gitRoot: "/repo/FinSpec",
shaExists: async () => false,
},
);

expect(result.status).toBe("unlinked");
expect(result.projectId).toBeNull();
});
});
75 changes: 75 additions & 0 deletions packages/core/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,81 @@ describe("ThreadRelinkService", () => {
.toEqual(expect.arrayContaining([oldRoot, newRoot]));
});

it("moves a stale automatic link when the current project has stronger evidence", async () => {
const base = await mkdtemp(join(tmpdir(), "threadrelink-stale-link-"));
cleanup.push(base);
const root = join(base, "internship-notes");
const registryHome = join(base, "state");
await mkdir(root);
await execFileAsync("git", ["init", root]);

const conversation: ThreadMetadata = {
provider: "codex",
id: "thread-stale-parent",
name: "Internship notes",
preview: "Internship notes",
cwd: root,
createdAt: 1,
updatedAt: 2,
archived: false,
cliVersion: "0.145.0",
modelProvider: "openai",
gitInfo: null,
};
const service = new ThreadRelinkService({
registryHome,
historyAdapterFactory: async () => adapterFor([conversation]),
now: () => new Date("2026-07-28T00:00:00.000Z"),
});
const currentProject = await service.initProject(root);
const originalCreatedAt = "2026-07-27T00:00:00.000Z";
await service.registry.update((draft) => {
draft.projects.push({
id: "legacy-parent",
name: "home",
kind: "git",
aliases: [{
path: base,
key: base,
firstSeenAt: originalCreatedAt,
lastSeenAt: originalCreatedAt,
}],
remotes: [],
createdAt: originalCreatedAt,
updatedAt: originalCreatedAt,
});
draft.threadLinks.push({
provider: "codex",
threadId: conversation.id,
projectId: "legacy-parent",
linkedBy: "automatic",
originalCwd: conversation.cwd,
relativeCwd: "internship-notes",
evidence: [{
kind: "path-alias",
confidence: 1,
description: "Legacy parent path match.",
}],
createdAt: originalCreatedAt,
updatedAt: originalCreatedAt,
});
});

const result = await service.sync(root);
const repairedLink = (await service.registry.read()).threadLinks.find(
(candidate) => candidate.threadId === conversation.id,
);

expect(result.linked.map((item) => item.thread.id))
.toContain(conversation.id);
expect(repairedLink).toMatchObject({
projectId: currentProject.id,
linkedBy: "automatic",
createdAt: originalCreatedAt,
evidence: [{ kind: "path-alias" }],
});
});

it("persists an explicit relink for an otherwise unverifiable move", async () => {
const base = await mkdtemp(join(tmpdir(), "threadrelink-relink-"));
cleanup.push(base);
Expand Down
7 changes: 7 additions & 0 deletions packages/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.4.1

- Repair stale automatic conversation links when the current project has
stronger path or Git evidence.
- Preserve manual links while preventing broad parent repositories from
claiming conversations with conflicting Git remotes.

## 0.4.0

- Rename the project and public interfaces to ThreadRelink.
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "threadrelink",
"displayName": "ThreadRelink",
"description": "A VS Code extension that keeps local Codex conversations discoverable after a repository is renamed or moved.",
"version": "0.4.0",
"version": "0.4.1",
"icon": "resources/threadrelink.png",
"publisher": "ascendho",
"repository": {
Expand Down