Skip to content

Commit ffc806e

Browse files
authored
feat!: align hot-path session memory with context-mode (#2)
## Summary - re-center the branch on an MCP-first `session_*` runtime, with in-process bounded execution, file processing, local indexing/search, and per-root-session corpus storage on Redis/FalkorDB - keep session continuity and long-term memory split correctly: plugin hooks own canonical root-session resolution, event capture, `<session_memory>` injection, and native-tool enforcement, while Graphiti stays asynchronous and off the hot path - harden runtime lifecycle and session-state behavior with shared teardown, temporary-root to canonical-root migration, bounded artifact/corpus overflow handling, and follow-up docs/config cleanup ## Key Changes - register an in-process `session_*` MCP surface from the plugin runtime: `session_execute`, `session_execute_file`, `session_batch_execute`, `session_index`, `session_search`, `session_fetch_and_index`, `session_stats`, and `session_doctor` - add the local session corpus layer for normalized indexing, bounded search/snippet retrieval, fetched-content ingestion, artifact storage, and per-root-session stats in Redis/FalkorDB - route large or risky work toward the MCP-first path via `tool.execute.before` / `tool.execute.after`, including native-tool guidance, argument rewriting, denial paths, and routing outcome metadata - preserve shared session state across parent/child and temporary/canonical root transitions by wiring runtime-state migration through `SessionManager`, the session MCP runtime, and corpus storage - retain the Redis-backed short-term memory + async Graphiti model: hot-path injection remains local/cache-backed, while Graphiti drain and cache refresh stay background-only - refresh README/plans/tests to describe the MCP-first architecture, local-first injection semantics, Graphiti-optional behavior, and current validation surface - update release workflow behavior in `.github/workflows/publish.yml` and `.github/scripts/version.ts` so publish gating, skip behavior, and tag/release creation stay consistent and idempotent even when npm publish is skipped ## Testing - add and expand focused Deno coverage for the MCP runtime, corpus, routing, runtime teardown, session migration, and hook integration paths - covered suites now include `src/services/session-mcp-runtime.test.ts`, `src/services/session-corpus.test.ts`, `src/services/tool-routing.test.ts`, `src/services/tool-guidance-cache.test.ts`, `src/services/tool-routing-outcome-cache.test.ts`, `src/handlers/tool-before.test.ts`, `src/handlers/tool-after.test.ts`, `src/session.test.ts`, and related index/handler/service tests - release/versioning coverage includes `.github/scripts/version.test.ts` ## Notes - this PR is no longer just the original Redis hot-tier rewrite; it now reflects the follow-up MCP-first replacement work and the later hardening passes on runtime lifecycle, corpus behavior, root-session migration, and release workflow handling - current docs intentionally describe hooks as enforcement/continuity plumbing, not the primary execution surface; the primary path is the in-process `session_*` MCP runtime
1 parent ada7c0b commit ffc806e

101 files changed

Lines changed: 40165 additions & 7094 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/version.test.ts

Lines changed: 405 additions & 1 deletion
Large diffs are not rendered by default.

.github/scripts/version.ts

Lines changed: 145 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* COMMIT_SHA - override for GITHUB_SHA (e.g. PR head SHA)
1111
*/
1212

13+
import { parse as parseJsonc } from "jsr:@std/jsonc@^1.0.2";
14+
1315
/** Semantic version bump type. */
1416
export type Bump = "major" | "minor" | "patch" | "none";
1517

@@ -18,16 +20,108 @@ export type VersionResult =
1820
| { skip: true }
1921
| { skip: false; version: string; tag: "latest" | "canary" };
2022

23+
export interface VersionCliDeps {
24+
cmd: (...command: string[]) => Promise<string>;
25+
readTextFile: (filePath: string) => Promise<string>;
26+
envGet: (name: string) => string | undefined;
27+
appendFile: (filePath: string, text: string) => void;
28+
log: (message: string) => void;
29+
now: () => Date;
30+
}
31+
32+
export interface CommandOutputResult {
33+
stdout: Uint8Array;
34+
stderr: Uint8Array;
35+
success: boolean;
36+
code: number;
37+
}
38+
39+
export function parseCommandOutput(
40+
command: string[],
41+
result: CommandOutputResult,
42+
): string {
43+
const stdoutText = new TextDecoder().decode(result.stdout).trim();
44+
const stderrText = new TextDecoder().decode(result.stderr).trim();
45+
46+
if (!result.success) {
47+
const stderrSuffix = stderrText ? `: ${stderrText}` : "";
48+
throw new Error(
49+
`Command failed with exit code ${result.code} (${
50+
command.join(" ")
51+
})${stderrSuffix}`,
52+
);
53+
}
54+
55+
return stdoutText;
56+
}
57+
58+
export async function runCommand(...command: string[]): Promise<string> {
59+
const proc = new Deno.Command(command[0], {
60+
args: command.slice(1),
61+
stdout: "piped",
62+
stderr: "piped",
63+
});
64+
return parseCommandOutput(command, await proc.output());
65+
}
66+
67+
function parsePackageManifest(text: string, filePath: string): unknown {
68+
if (filePath.endsWith(".jsonc")) {
69+
return parseJsonc(text);
70+
}
71+
72+
return JSON.parse(text);
73+
}
74+
75+
function getPackageNameFromManifest(manifest: unknown): string | undefined {
76+
if (
77+
manifest &&
78+
typeof manifest === "object" &&
79+
"name" in manifest &&
80+
typeof manifest.name === "string"
81+
) {
82+
return manifest.name;
83+
}
84+
85+
return undefined;
86+
}
87+
88+
const defaultVersionCliDeps: VersionCliDeps = {
89+
cmd: (...command: string[]) => runCommand(...command),
90+
readTextFile: (filePath) => Deno.readTextFile(filePath),
91+
envGet: (name) => Deno.env.get(name),
92+
appendFile: (filePath, text) => {
93+
Deno.writeTextFileSync(filePath, text, { append: true });
94+
},
95+
log: (message) => console.log(message),
96+
now: () => new Date(),
97+
};
98+
99+
/**
100+
* Returns true when any commit body contains a semantic-release style breaking
101+
* change footer/header such as `BREAKING CHANGE: details`.
102+
*/
103+
export function hasBreakingChangeBody(bodies: string[]): boolean {
104+
return bodies.some((body) => /^BREAKING CHANGE:/im.test(body));
105+
}
106+
21107
/**
22-
* Analyze conventional commit subjects and return the highest bump type.
108+
* Analyze conventional commits and return the highest bump type.
109+
*
110+
* Supported formats:
111+
* - `feat: add feature` -> minor
112+
* - `fix: resolve bug` / `perf: speed up path` -> patch
113+
* - `feat!: breaking api change` / `fix!: breaking bugfix` -> major
114+
* - `BREAKING CHANGE: explanation` in a commit body -> major
115+
* - `Release-As: x.y.z` is handled separately as an exact override
23116
*
24-
* Rules:
25-
* - `BREAKING CHANGE` in body or `type!:` → major
26-
* - `feat:` → minor
27-
* - `fix:` / `perf:` → patch
28-
* - Anything else → none
117+
* In `0.x`, a major bump resolves to the next minor version.
29118
*/
30-
export function analyzeCommits(subjects: string[]): Bump {
119+
export function analyzeCommits(
120+
subjects: string[],
121+
bodies: string[] = [],
122+
): Bump {
123+
if (hasBreakingChangeBody(bodies)) return "major";
124+
31125
let bump: Bump = "none";
32126

33127
for (const msg of subjects) {
@@ -111,6 +205,17 @@ export function hasNonTestChanges(changedFiles: string[]): boolean {
111205
return changedFiles.some((file) => file && !file.endsWith(".test.ts"));
112206
}
113207

208+
/** Parse newline-separated changed-file output into a stable unique list. */
209+
export function parseChangedFiles(output: string): string[] {
210+
return [
211+
...new Set(
212+
output.split("\n").map((line) => line.trim()).filter(
213+
Boolean,
214+
),
215+
),
216+
];
217+
}
218+
114219
/**
115220
* Calculate the next version given all inputs.
116221
*
@@ -121,7 +226,7 @@ export function calculateVersion(opts: {
121226
currentVersion: string;
122227
/** Conventional commit subjects since last release. */
123228
subjects: string[];
124-
/** Commit bodies (for Release-As detection). */
229+
/** Commit bodies (for Release-As and BREAKING CHANGE detection). */
125230
bodies: string[];
126231
/** Whether this is a "push" (release) or "pull_request" (canary). */
127232
eventName: "push" | "pull_request";
@@ -151,8 +256,8 @@ export function calculateVersion(opts: {
151256
return { skip: false, version, tag } as const;
152257
}
153258

154-
// Analyze commits
155-
let bump = analyzeCommits(opts.subjects);
259+
// Analyze commits using subjects plus semantic-release style body footers.
260+
let bump = analyzeCommits(opts.subjects, opts.bodies);
156261

157262
// When no git tags, default to patch bump from npm baseline
158263
if (opts.noGitTags && bump === "none") {
@@ -185,45 +290,40 @@ export function calculateVersion(opts: {
185290
// CLI entry point
186291
// ---------------------------------------------------------------------------
187292

188-
async function run(args: string[]): Promise<void> {
189-
const cmd = async (...command: string[]): Promise<string> => {
190-
const proc = new Deno.Command(command[0], {
191-
args: command.slice(1),
192-
stdout: "piped",
193-
stderr: "piped",
194-
});
195-
const { stdout } = await proc.output();
196-
return new TextDecoder().decode(stdout).trim();
197-
};
198-
293+
export async function run(
294+
args: string[],
295+
deps: VersionCliDeps = defaultVersionCliDeps,
296+
): Promise<void> {
297+
const { cmd, readTextFile, envGet, appendFile, log, now } = deps;
199298
const output = (key: string, value: string): void => {
200-
const ghOutput = Deno.env.get("GITHUB_OUTPUT");
299+
const ghOutput = envGet("GITHUB_OUTPUT");
201300
if (ghOutput) {
202-
Deno.writeTextFileSync(ghOutput, `${key}=${value}\n`, { append: true });
301+
appendFile(ghOutput, `${key}=${value}\n`);
203302
}
204-
console.log(`${key}=${value}`);
303+
log(`${key}=${value}`);
205304
};
206305

207306
// Read package name from deno.json or package.json
208307
let packageName = "unknown";
209308
for (const file of ["deno.json", "deno.jsonc", "package.json"]) {
210309
try {
211-
const text = await Deno.readTextFile(file);
212-
const json = JSON.parse(text);
213-
if (json.name) {
214-
packageName = json.name;
310+
const text = await readTextFile(file);
311+
const manifest = parsePackageManifest(text, file);
312+
const manifestPackageName = getPackageNameFromManifest(manifest);
313+
if (manifestPackageName) {
314+
packageName = manifestPackageName;
215315
break;
216316
}
217317
} catch {
218318
continue;
219319
}
220320
}
221321

222-
const eventName = (Deno.env.get("GITHUB_EVENT_NAME") ?? args[0] ?? "push") as
322+
const eventName = (envGet("GITHUB_EVENT_NAME") ?? args[0] ?? "push") as
223323
| "push"
224324
| "pull_request";
225-
const commitSha = Deno.env.get("COMMIT_SHA") ??
226-
Deno.env.get("GITHUB_SHA") ??
325+
const commitSha = envGet("COMMIT_SHA") ??
326+
envGet("GITHUB_SHA") ??
227327
args[1] ??
228328
await cmd("git", "rev-parse", "HEAD");
229329

@@ -250,8 +350,9 @@ async function run(args: string[]): Promise<void> {
250350
currentVersion = npmVersion || "0.0.0";
251351
subjects = (await cmd("git", "log", "--format=%s")).split("\n");
252352
bodies = (await cmd("git", "log", "--format=%b")).split("\n");
253-
changedFiles = (await cmd("git", "ls-tree", "-r", "--name-only", "HEAD"))
254-
.split("\n");
353+
changedFiles = parseChangedFiles(
354+
await cmd("git", "log", "--format=", "--name-only"),
355+
);
255356
noGitTags = true;
256357
} else {
257358
currentVersion = latestTag.replace(/^v/, "");
@@ -267,16 +368,18 @@ async function run(args: string[]): Promise<void> {
267368
`${latestTag}..HEAD`,
268369
"--format=%b",
269370
)).split("\n");
270-
changedFiles = (await cmd(
271-
"git",
272-
"diff",
273-
"--name-only",
274-
`${latestTag}..HEAD`,
275-
)).split("\n");
371+
changedFiles = parseChangedFiles(
372+
await cmd(
373+
"git",
374+
"diff",
375+
"--name-only",
376+
`${latestTag}..HEAD`,
377+
),
378+
);
276379
noGitTags = false;
277380
}
278381

279-
const timestamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
382+
const timestamp = now().toISOString().replace(/[-:T]/g, "").slice(0, 14);
280383

281384
const result = calculateVersion({
282385
currentVersion,
@@ -291,13 +394,13 @@ async function run(args: string[]): Promise<void> {
291394

292395
if (result.skip) {
293396
output("skip", "true");
294-
console.log(
397+
log(
295398
`No release-triggering commits since ${latestTag || "initial"}, skipping`,
296399
);
297400
} else {
298401
output("version", result.version);
299402
output("tag", result.tag);
300-
console.log(
403+
log(
301404
`${
302405
result.tag === "canary" ? "Canary" : "Release"
303406
} version: ${result.version}`,

.github/workflows/publish.yml

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,27 +42,42 @@ jobs:
4242
with:
4343
node-version: 24
4444

45-
- name: Check if version exists
45+
- name: Check if version exists on npm
4646
if: steps.version.outputs.skip != 'true'
47-
id: check
47+
id: npm
4848
run: |
4949
if npm view "opencode-graphiti@${{ steps.version.outputs.version }}" version 2>/dev/null; then
50-
echo "skip=true" >> "$GITHUB_OUTPUT"
51-
echo "Version ${{ steps.version.outputs.version }} already exists, skipping"
50+
echo "publish=false" >> "$GITHUB_OUTPUT"
51+
echo "Version ${{ steps.version.outputs.version }} already exists on npm, skipping publish"
5252
else
53-
echo "skip=false" >> "$GITHUB_OUTPUT"
53+
echo "publish=true" >> "$GITHUB_OUTPUT"
5454
fi
5555
5656
- name: Publish
57-
if: steps.version.outputs.skip != 'true' && steps.check.outputs.skip != 'true'
57+
if: steps.version.outputs.skip != 'true' && steps.npm.outputs.publish == 'true'
5858
working-directory: dist
5959
run: npm publish --provenance --access public --tag ${{ steps.version.outputs.tag }}
6060

6161
- name: Tag and Release
62-
if: github.event_name == 'push' && steps.version.outputs.skip != 'true' && steps.check.outputs.skip != 'true'
62+
if: github.event_name == 'push' && steps.version.outputs.skip != 'true'
6363
run: |
64-
git tag "v${{ steps.version.outputs.version }}"
65-
git push origin "v${{ steps.version.outputs.version }}"
66-
gh release create "v${{ steps.version.outputs.version }}" --generate-notes
64+
set -euo pipefail
65+
tag="v${{ steps.version.outputs.version }}"
66+
67+
if git show-ref --verify --quiet "refs/tags/$tag"; then
68+
echo "Tag $tag already exists locally"
69+
elif git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
70+
git fetch --tags origin
71+
echo "Tag $tag already exists on origin"
72+
else
73+
git tag "$tag"
74+
git push origin "$tag"
75+
fi
76+
77+
if gh release view "$tag" >/dev/null 2>&1; then
78+
echo "Release $tag already exists"
79+
else
80+
gh release create "$tag" --generate-notes
81+
fi
6782
env:
6883
GH_TOKEN: ${{ github.token }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
.opencode/
2+
.swarm/
23

34
dist/
45

56
node_modules/
7+
.worktrees/

0 commit comments

Comments
 (0)