Skip to content

Commit 7528cdb

Browse files
committed
refactor: remove deprecated .graphitirc file and update README for config locations
feat(normalization): add sdk-normalize utility for consistent episode handling fix: improve session management by optimizing key deletion logic test: enhance group ID generation tests for edge cases
1 parent 03c474e commit 7528cdb

13 files changed

Lines changed: 188 additions & 67 deletions

File tree

.graphitirc

Lines changed: 0 additions & 3 deletions
This file was deleted.

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@ automatically.
9999

100100
## Configuration
101101

102-
Create a config file at `~/.config/opencode/graphiti.jsonc`:
102+
Supported config locations, in lookup order:
103+
104+
1. The provided project directory: `package.json#graphiti`, `.graphitirc`, and other standard `cosmiconfig` `graphiti` filenames
105+
2. Standard global/home `graphiti` config locations discovered by `cosmiconfig` (for example `~/.graphitirc`)
106+
3. Legacy fallback: `~/.config/opencode/.graphitirc`
107+
108+
Example `.graphitirc`:
103109

104110
```jsonc
105111
{

src/config.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cosmiconfigSync } from "cosmiconfig";
2+
import os from "node:os";
23
import * as z from "zod/mini";
34
import type { GraphitiConfig } from "./types/index.ts";
45

@@ -16,15 +17,6 @@ const GraphitiConfigSchema = z.object({
1617
factStaleDays: z.number(),
1718
});
1819

19-
function searchConfig(searchStrategy: "none" | "global", directory?: string) {
20-
const explorer = cosmiconfigSync("graphiti", {
21-
searchStrategy,
22-
cache: false,
23-
});
24-
25-
return directory ? explorer.search(directory) : explorer.search();
26-
}
27-
2820
/**
2921
* Load Graphiti configuration from JSONC files with defaults applied.
3022
*
@@ -35,14 +27,18 @@ function searchConfig(searchStrategy: "none" | "global", directory?: string) {
3527
* global search (home directory and OS-level config locations).
3628
*/
3729
export function loadConfig(directory?: string): GraphitiConfig {
38-
const result = directory
39-
? searchConfig("none", directory) ?? searchConfig("global")
40-
: searchConfig("global");
30+
const result = cosmiconfigSync("graphiti", {
31+
stopDir: os.homedir(),
32+
mergeSearchPlaces: true,
33+
cache: false,
34+
}).search(directory) ??
35+
cosmiconfigSync("graphiti", {
36+
searchPlaces: [`${os.homedir()}/.graphitirc`],
37+
}).search();
4138

42-
const candidate = result?.config ?? {};
4339
const merged = {
4440
...DEFAULT_CONFIG,
45-
...candidate,
41+
...result?.config,
4642
};
4743
const parsed = GraphitiConfigSchema.safeParse(merged);
4844
if (parsed.success) {

src/handlers/chat.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it } from "jsr:@std/testing@^1.0.0/bdd";
33
import type { GraphitiFact, GraphitiNode } from "../types/index.ts";
44
import type { SessionManager } from "../session.ts";
55
import type { GraphitiClient } from "../services/client.ts";
6+
import { normalizeEpisode } from "../services/sdk-normalize.ts";
67
import { createChatHandler } from "./chat.ts";
78

89
// Mock SessionManager
@@ -100,7 +101,9 @@ class MockGraphitiClient implements Partial<GraphitiClient> {
100101
groupId: params.groupId || "",
101102
lastN: params.lastN || 10,
102103
});
103-
return Promise.resolve(this.episodesResult);
104+
// Mirror the real GraphitiClient boundary: normalize casing so tests
105+
// that supply snake_case source_description are handled correctly.
106+
return Promise.resolve(this.episodesResult.map(normalizeEpisode));
104107
}
105108
}
106109

src/handlers/chat.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,7 @@ export function createChatHandler(deps: ChatHandlerDeps) {
147147
});
148148
const snapshot = episodes
149149
.filter((episode) => {
150-
const description = episode.sourceDescription ??
151-
episode.source_description ?? "";
150+
const description = episode.sourceDescription ?? "";
152151
return description === "session-snapshot";
153152
})
154153
.sort((a, b) => {

src/handlers/event.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,19 +231,23 @@ export function createEventHandler(deps: EventHandlerDeps) {
231231
);
232232

233233
if (info.tokens && info.providerID && info.modelID) {
234+
// Fire-and-forget: update contextLimit asynchronously without
235+
// blocking event responsiveness. The state update is eventually
236+
// consistent — a missed update only affects injection budget sizing,
237+
// not correctness. We snapshot `state` here; if the session is
238+
// deleted before the promise resolves the write is a harmless no-op.
239+
const capturedState = state;
234240
resolveContextLimit(
235241
info.providerID as string,
236242
info.modelID as string,
237243
sdkClient,
238244
directory,
239245
contextLimitCache,
240-
)
241-
.then((limit) => {
242-
state.contextLimit = limit;
243-
})
244-
.catch((err) =>
245-
logger.debug("Failed to resolve context limit", err)
246-
);
246+
).then((limit) => {
247+
capturedState.contextLimit = limit;
248+
}).catch((err) =>
249+
logger.debug("Failed to resolve context limit", err)
250+
);
247251
}
248252
return;
249253
}

src/index.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { assertEquals } from "jsr:@std/assert@^1.0.0";
22
import { describe, it } from "jsr:@std/testing@^1.0.0/bdd";
3-
import { makeGroupId } from "./utils.ts";
3+
import { makeGroupId, makeUserGroupId } from "./utils.ts";
44

55
describe("index", () => {
66
describe("makeGroupId", () => {
7+
it("should omit undefined prefix text when prefix is missing", () => {
8+
const groupId = makeGroupId(undefined, "/home/user/my-project");
9+
assertEquals(groupId, "my-project__main");
10+
});
11+
712
it("should create group ID from simple directory path", () => {
813
const groupId = makeGroupId("opencode", "/home/user/my-project");
914
assertEquals(groupId, "opencode-my-project__main");
@@ -94,6 +99,14 @@ describe("index", () => {
9499
});
95100
});
96101

102+
describe("makeUserGroupId", () => {
103+
it("should omit undefined prefix text when prefix is missing", () => {
104+
const groupId = makeUserGroupId(undefined, "/home/user/my-project");
105+
assertEquals(groupId.startsWith("undefined"), false);
106+
assertEquals(groupId.startsWith("my-project__user-"), true);
107+
});
108+
});
109+
97110
// NOTE: The main `graphiti()` plugin function requires a live Graphiti MCP
98111
// server and cannot be integration-tested here without mocking the MCP
99112
// transport layer. All testable units are covered in the files listed below:

src/services/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
GraphitiNode,
88
} from "../types/index.ts";
99
import { logger } from "./logger.ts";
10+
import { normalizeEpisode } from "./sdk-normalize.ts";
1011

1112
/**
1213
* Graphiti MCP client wrapper for connecting, querying,
@@ -248,7 +249,9 @@ export class GraphitiClient {
248249
group_id: params.groupId,
249250
last_n: params.lastN,
250251
});
251-
return this.parseWrappedArray<GraphitiEpisode>(result, "episodes") ?? [];
252+
const raw = this.parseWrappedArray<GraphitiEpisode>(result, "episodes") ??
253+
[];
254+
return raw.map(normalizeEpisode);
252255
} catch (err) {
253256
logger.error("getEpisodes error:", err);
254257
return [];

src/services/context-limit.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OpencodeClient } from "@opencode-ai/sdk";
22
import { DEFAULT_CONTEXT_LIMIT } from "./constants.ts";
33
import { logger } from "./logger.ts";
4+
import { extractSdkProviders } from "./sdk-normalize.ts";
45

56
export async function resolveContextLimit(
67
providerID: string,
@@ -14,21 +15,16 @@ export async function resolveContextLimit(
1415
if (cached) return cached;
1516

1617
try {
17-
const providers = await client.provider.list({
18+
const response = await client.provider.list({
1819
query: { directory },
1920
});
20-
const list = (providers as { providers?: unknown[] }).providers ?? [];
21+
const list = extractSdkProviders(response);
2122
for (const provider of list) {
22-
const providerInfo = provider as { id?: string; models?: unknown[] };
23-
if (providerInfo.id !== providerID) continue;
24-
const models = providerInfo.models ?? [];
23+
if (provider.id !== providerID) continue;
24+
const models = provider.models ?? [];
2525
for (const model of models) {
26-
const modelInfo = model as {
27-
id?: string;
28-
limit?: { context?: number };
29-
};
30-
if (modelInfo.id !== modelID) continue;
31-
const contextLimit = modelInfo.limit?.context;
26+
if (model.id !== modelID) continue;
27+
const contextLimit = model.limit?.context;
3228
if (typeof contextLimit === "number" && contextLimit > 0) {
3329
cache.set(modelKey, contextLimit);
3430
return contextLimit;

src/services/sdk-normalize.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Part, SessionMessagesResponses } from "@opencode-ai/sdk";
2+
import type { GraphitiEpisode } from "../types/index.ts";
3+
4+
/**
5+
* Narrow type for a single SDK message entry as returned by
6+
* `session.messages()`.
7+
*/
8+
export type SdkMessage = {
9+
info: { role?: string; id?: string };
10+
parts: Part[];
11+
};
12+
13+
/**
14+
* Normalize an SDK response that may be wrapped in `{ data: … }` or returned
15+
* directly. Returns the inner value cast to `T`, or `undefined` when the
16+
* response is absent.
17+
*
18+
* This replaces the repeated `"data" in response ? (response as
19+
* { data?: … }).data : response` pattern found in session.ts and
20+
* context-limit.ts.
21+
*/
22+
export function unwrapSdkResponse<T>(response: unknown): T | undefined {
23+
if (response == null) return undefined;
24+
if (typeof response === "object" && "data" in (response as object)) {
25+
return (response as { data?: T }).data;
26+
}
27+
return response as T;
28+
}
29+
30+
/**
31+
* Extract the messages array from a raw `session.messages()` response.
32+
* Returns an empty array when the response is missing or malformed.
33+
*/
34+
export function extractSdkMessages(
35+
response: unknown,
36+
): SdkMessage[] {
37+
const payload = unwrapSdkResponse<SessionMessagesResponses[200]>(response);
38+
return Array.isArray(payload) ? (payload as SdkMessage[]) : [];
39+
}
40+
41+
/**
42+
* Extract the provider list from a raw `provider.list()` response.
43+
* Returns an empty array when the response is missing or malformed.
44+
*/
45+
export type SdkProvider = {
46+
id?: string;
47+
models?: SdkModel[];
48+
};
49+
50+
export type SdkModel = {
51+
id?: string;
52+
limit?: { context?: number };
53+
};
54+
55+
export function extractSdkProviders(response: unknown): SdkProvider[] {
56+
// provider.list() may return `{ providers: [...] }` directly (no data wrap).
57+
if (response != null && typeof response === "object") {
58+
const obj = response as Record<string, unknown>;
59+
if (Array.isArray(obj["providers"])) {
60+
return obj["providers"] as SdkProvider[];
61+
}
62+
if ("data" in obj) {
63+
const data = obj["data"];
64+
if (data != null && typeof data === "object") {
65+
const dataObj = data as Record<string, unknown>;
66+
if (Array.isArray(dataObj["providers"])) {
67+
return dataObj["providers"] as SdkProvider[];
68+
}
69+
}
70+
if (Array.isArray(data)) return data as SdkProvider[];
71+
}
72+
}
73+
return [];
74+
}
75+
76+
/**
77+
* Normalize a raw Graphiti episode object so that `sourceDescription` is
78+
* always the canonical field regardless of whether the payload used
79+
* camelCase (`sourceDescription`) or snake_case (`source_description`).
80+
*
81+
* Call this at the API boundary (e.g. inside `GraphitiClient.getEpisodes`)
82+
* so all downstream consumers only need to read `episode.sourceDescription`.
83+
*/
84+
export function normalizeEpisode(
85+
raw: GraphitiEpisode & {
86+
source_description?: string;
87+
},
88+
): GraphitiEpisode {
89+
const { source_description, ...rest } = raw;
90+
return {
91+
...rest,
92+
sourceDescription: rest.sourceDescription ?? source_description,
93+
};
94+
}

0 commit comments

Comments
 (0)