opencode-engram 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import type { Event } from "@opencode-ai/sdk";
2
+
3
+ export const builtInNavigatorPromptBody = `This session was spawned from an upstream conversation. Your task description
4
+ carries intent but not the full discussion that shaped it — requirements,
5
+ constraints, and rejected approaches may exist only in the upstream history.
6
+
7
+ ## Common Patterns
8
+
9
+ | Scenario | Strategy |
10
+ |----------|----------|
11
+ | Continue prior work | history_browse_turns → history_browse_messages on 2-3 recent turns → history_pull_message if needed |
12
+ | Known topic / "as we discussed X" | history_search(X) → history_pull_message if snippet insufficient |
13
+ | Check what files were changed | history_browse_messages on recent turns → inspect attachments and tool calls |`;
14
+
15
+ function buildNavigatorSessionIdLine(parentId: string) {
16
+ return `**Upstream session ID: ${parentId}**`;
17
+ }
18
+
19
+ export function buildNavigatorPrompt(parentId: string) {
20
+ return `${buildNavigatorSessionIdLine(parentId)}
21
+
22
+ ${builtInNavigatorPromptBody}`;
23
+ }
24
+
25
+ const upstreamNavigatorWaitTimeoutMs = 50;
26
+
27
+ type SessionChildState = {
28
+ resolved: boolean;
29
+ parentId: string | undefined;
30
+ waiters: Array<(parentId: string | undefined) => void>;
31
+ };
32
+
33
+ type UpstreamNavigatorState = {
34
+ sessionChildStates: Map<string, SessionChildState>;
35
+ };
36
+
37
+ function getOrCreateSessionChildState(states: Map<string, SessionChildState>, sessionID: string): SessionChildState {
38
+ const existing = states.get(sessionID);
39
+ if (existing) {
40
+ return existing;
41
+ }
42
+
43
+ const created: SessionChildState = {
44
+ resolved: false,
45
+ parentId: undefined,
46
+ waiters: [],
47
+ };
48
+ states.set(sessionID, created);
49
+ return created;
50
+ }
51
+
52
+ function resolveSessionChildState(state: SessionChildState, parentId: string | undefined) {
53
+ if (state.resolved) {
54
+ return;
55
+ }
56
+
57
+ state.resolved = true;
58
+ state.parentId = parentId;
59
+ const waiters = state.waiters.splice(0);
60
+ for (const waiter of waiters) {
61
+ waiter(parentId);
62
+ }
63
+ }
64
+
65
+ async function waitForSessionChildState(
66
+ state: SessionChildState,
67
+ timeoutMs: number,
68
+ ): Promise<string | undefined> {
69
+ if (state.resolved) {
70
+ return state.parentId;
71
+ }
72
+
73
+ return new Promise((resolve) => {
74
+ const timer = setTimeout(() => {
75
+ state.waiters = state.waiters.filter((waiter) => waiter !== resolve);
76
+ resolve(undefined);
77
+ }, timeoutMs);
78
+
79
+ state.waiters.push((parentId) => {
80
+ clearTimeout(timer);
81
+ resolve(parentId);
82
+ });
83
+ });
84
+ }
85
+
86
+ function isSessionCreatedEvent(event: Event): event is Extract<Event, { type: "session.created" }> {
87
+ return event.type === "session.created";
88
+ }
89
+
90
+ export function createUpstreamNavigatorState(): UpstreamNavigatorState {
91
+ return {
92
+ sessionChildStates: new Map<string, SessionChildState>(),
93
+ };
94
+ }
95
+
96
+ export function recordUpstreamNavigatorSession(
97
+ state: UpstreamNavigatorState,
98
+ event: Event,
99
+ ) {
100
+ if (!isSessionCreatedEvent(event)) {
101
+ return;
102
+ }
103
+
104
+ const session = event.properties.info;
105
+ const sessionState = getOrCreateSessionChildState(state.sessionChildStates, session.id);
106
+ resolveSessionChildState(sessionState, session.parentID || undefined);
107
+ }
108
+
109
+ export async function injectUpstreamNavigatorPrompt(
110
+ state: UpstreamNavigatorState,
111
+ sessionID: string | undefined,
112
+ system: string[],
113
+ ) {
114
+ if (!sessionID) {
115
+ return;
116
+ }
117
+
118
+ const sessionState = getOrCreateSessionChildState(state.sessionChildStates, sessionID);
119
+ const parentId = await waitForSessionChildState(
120
+ sessionState,
121
+ upstreamNavigatorWaitTimeoutMs,
122
+ );
123
+ if (!parentId) {
124
+ return;
125
+ }
126
+
127
+ const prompt = buildNavigatorPrompt(parentId);
128
+ if (!system.includes(prompt)) {
129
+ system.push(prompt);
130
+ }
131
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * core/index.ts - Core Layer Public Exports
3
+ *
4
+ * Public API for the core layer. Runtime modules use these exports
5
+ * to resolve targets and create browse contexts.
6
+ *
7
+ * Architectural boundary:
8
+ * - Interface layer (`src/plugin/index.ts`) defines tool contracts only
9
+ * - Runtime layer (`src/runtime/runtime.ts`) wires SDK calls to core abstractions
10
+ * - Core modules provide target and browse primitives
11
+ * - Serialization layer (`src/domain/serialize.ts`) remains separate
12
+ */
13
+
14
+ // Session types, metadata, context, and utilities
15
+ export type {
16
+ SessionMetadata,
17
+ SessionTarget,
18
+ BrowseContext,
19
+ SdkSessionData,
20
+ } from "./session.ts";
21
+ export {
22
+ createBrowseContext,
23
+ normalizeSessionMetadata,
24
+ toSessionMetadata,
25
+ createSessionTarget,
26
+ computeCacheFingerprint,
27
+ } from "./session.ts";
28
+
29
+ // SDK bridges for session resolution
30
+ export {
31
+ getParentSessionId,
32
+ resolveSessionTarget,
33
+ } from "./sdk-bridge.ts";
@@ -0,0 +1,73 @@
1
+ /**
2
+ * sdk-bridge.ts - SDK-backed Session Resolution
3
+ *
4
+ * Wires core abstractions to the OpenCode SDK for session reading.
5
+ * Provides two low-level primitives:
6
+ * - getParentSessionId: retrieve the parentID from a session
7
+ * - resolveSessionTarget: load a session by ID and wrap as SessionTarget
8
+ */
9
+
10
+ import type { createOpencodeClient } from "@opencode-ai/sdk";
11
+
12
+ import { createSessionTarget, type SdkSessionData } from "./session.ts";
13
+
14
+ type SdkClient = ReturnType<typeof createOpencodeClient>;
15
+
16
+ function toQuery(directory?: string): { directory: string } | undefined {
17
+ if (!directory?.trim()) {
18
+ return undefined;
19
+ }
20
+ return { directory };
21
+ }
22
+
23
+ async function getSessionOrThrow(
24
+ client: SdkClient,
25
+ sessionId: string,
26
+ directory?: string,
27
+ ): Promise<SdkSessionData> {
28
+ const result = await client.session.get({
29
+ path: { id: sessionId },
30
+ query: toQuery(directory),
31
+ throwOnError: false,
32
+ });
33
+
34
+ const status = result.response?.status ?? 0;
35
+ if (status === 404) {
36
+ throw new Error(`Session '${sessionId}' not found`);
37
+ }
38
+ if (result.error || status >= 400 || !result.data) {
39
+ throw new Error(
40
+ `Failed to load session '${sessionId}'. This may be a temporary issue — try again.`,
41
+ );
42
+ }
43
+
44
+ return result.data;
45
+ }
46
+
47
+ /**
48
+ * Get the parentID from a session.
49
+ *
50
+ * Returns undefined if the session has no parent.
51
+ */
52
+ export async function getParentSessionId(
53
+ client: SdkClient,
54
+ sessionId: string,
55
+ directory?: string,
56
+ ): Promise<string | undefined> {
57
+ const session = await getSessionOrThrow(client, sessionId, directory);
58
+ return session.parentID || undefined;
59
+ }
60
+
61
+ /**
62
+ * Resolve a session target by session ID.
63
+ *
64
+ * Loads the session data from the SDK and wraps it as a SessionTarget.
65
+ */
66
+ export async function resolveSessionTarget(
67
+ client: SdkClient,
68
+ sessionId: string,
69
+ directory?: string,
70
+ ) {
71
+ const session = await getSessionOrThrow(client, sessionId, directory);
72
+ return createSessionTarget(session);
73
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * session.ts - Core Session Models and Utilities
3
+ *
4
+ * Unified module for session targeting, metadata normalization, browse context,
5
+ * and SDK session adapters used by the core layer.
6
+ *
7
+ * Key concepts:
8
+ * - SessionTarget: the resolved target session for reading
9
+ * - BrowseContext: unified context for all core reading operations
10
+ * - SessionMetadata: minimal metadata needed for reading
11
+ * - SdkSessionData: shape of SDK session data for conversion
12
+ */
13
+
14
+ // =============================================================================
15
+ // Session Metadata
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Minimal metadata needed to read a session's history.
20
+ * Intentionally minimal to avoid coupling to SDK-specific types.
21
+ */
22
+ export interface SessionMetadata {
23
+ id: string;
24
+ title: string;
25
+ version: number | undefined;
26
+ updatedAt: number | undefined;
27
+ parentId: string | undefined;
28
+ }
29
+
30
+ // =============================================================================
31
+ // Session Target
32
+ // =============================================================================
33
+
34
+ /**
35
+ * A resolved session target for core reading operations.
36
+ *
37
+ * All targets are treated uniformly by core operations —
38
+ * the core layer only sees a session to read from.
39
+ */
40
+ export interface SessionTarget {
41
+ session: SessionMetadata;
42
+ }
43
+
44
+ // =============================================================================
45
+ // Browse Context
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Context for a core browsing operation.
50
+ *
51
+ * This context is passed to core timeline/read/summary operations.
52
+ * It contains the resolved target and any operation-specific parameters
53
+ * needed by the core layer.
54
+ *
55
+ * The context intentionally does NOT include:
56
+ * - Tool-specific argument validation (interface layer responsibility)
57
+ * - Public output field filtering (serialize layer responsibility)
58
+ * - SDK client references (passed separately to avoid coupling)
59
+ */
60
+ export interface BrowseContext {
61
+ /**
62
+ * The resolved session target to browse.
63
+ */
64
+ target: SessionTarget;
65
+
66
+ /**
67
+ * True when the target session is the caller's own session.
68
+ *
69
+ * This is detected once at the runtime boundary and propagated through
70
+ * all data paths so tool implementations can apply self-session semantics
71
+ * without adding new parameters.
72
+ */
73
+ selfSession: boolean;
74
+ }
75
+
76
+ /**
77
+ * Create a browse context from a resolved target.
78
+ *
79
+ * @param target Resolved session target
80
+ * @returns Browse context for core operations
81
+ */
82
+ export function createBrowseContext(
83
+ target: SessionTarget,
84
+ selfSession: boolean = false,
85
+ ): BrowseContext {
86
+ return {
87
+ target,
88
+ selfSession,
89
+ };
90
+ }
91
+
92
+ // =============================================================================
93
+ // SDK Session Adapter
94
+ // =============================================================================
95
+
96
+ /**
97
+ * SDK session data shape.
98
+ *
99
+ * Captures the shape of session data returned by the SDK,
100
+ * allowing conversion to core SessionMetadata.
101
+ */
102
+ export interface SdkSessionData {
103
+ id: string;
104
+ title: string;
105
+ version?: number | string;
106
+ time?: {
107
+ updated?: number | string;
108
+ };
109
+ parentID?: string;
110
+ }
111
+
112
+ // =============================================================================
113
+ // Session Metadata Normalization
114
+ // =============================================================================
115
+
116
+ /**
117
+ * Normalize a flexible numeric value from SDK/session layer.
118
+ */
119
+ function normalizeVersion(value: number | string | undefined): number | undefined {
120
+ if (value === undefined) {
121
+ return undefined;
122
+ }
123
+
124
+ const parsed = typeof value === "number"
125
+ ? value
126
+ : parseInt(String(value), 10);
127
+ return Number.isNaN(parsed) ? undefined : parsed;
128
+ }
129
+
130
+ /**
131
+ * Normalize a flexible timestamp value from SDK/session layer.
132
+ */
133
+ function normalizeUpdatedAt(
134
+ value: number | string | undefined,
135
+ ): number | undefined {
136
+ if (value === undefined) {
137
+ return undefined;
138
+ }
139
+
140
+ const parsed = typeof value === "number"
141
+ ? value
142
+ : Date.parse(String(value));
143
+ return Number.isNaN(parsed) ? undefined : parsed;
144
+ }
145
+
146
+ /**
147
+ * Convert SDK session data to core SessionMetadata.
148
+ */
149
+ export function normalizeSessionMetadata(session: SdkSessionData): SessionMetadata {
150
+ return {
151
+ id: session.id,
152
+ title: session.title,
153
+ version: normalizeVersion(session.version),
154
+ updatedAt: normalizeUpdatedAt(session.time?.updated),
155
+ parentId: session.parentID,
156
+ };
157
+ }
158
+
159
+ // =============================================================================
160
+ // Session Target Factory
161
+ // =============================================================================
162
+
163
+ /**
164
+ * Convert SDK session data to core SessionMetadata.
165
+ */
166
+ export function toSessionMetadata(sdk: SdkSessionData): SessionMetadata {
167
+ return normalizeSessionMetadata(sdk);
168
+ }
169
+
170
+ /**
171
+ * Create a SessionTarget from SDK session data.
172
+ */
173
+ export function createSessionTarget(
174
+ sdk: SdkSessionData,
175
+ ): SessionTarget {
176
+ return {
177
+ session: toSessionMetadata(sdk),
178
+ };
179
+ }
180
+
181
+ // =============================================================================
182
+ // Session Fingerprint (for cache invalidation)
183
+ // =============================================================================
184
+
185
+ /**
186
+ * Compute a cache fingerprint string from session metadata.
187
+ * Returns undefined if required metadata is unavailable.
188
+ */
189
+ export function computeCacheFingerprint(
190
+ session: SessionMetadata,
191
+ ): string | undefined {
192
+ if (session.version === undefined || session.updatedAt === undefined) {
193
+ return undefined;
194
+ }
195
+ return `${session.version}:${session.updatedAt}`;
196
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * turn-index.ts - Reusable Turn Cache for Upstream History
3
+ *
4
+ * This module provides a session-scoped turn cache that:
5
+ * - Computes turn mappings once per session version
6
+ * - Invalidates when session metadata changes (version/updated time)
7
+ * - Falls back to full scan when cache unavailable or stale
8
+ * - Logs cache hit/rebuild/fallback events
9
+ */
10
+
11
+ import type { TurnComputeItem } from "../domain/domain.ts";
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Session fingerprint used for cache invalidation.
19
+ * Combines version and updated time to detect any upstream changes.
20
+ */
21
+ export interface SessionFingerprint {
22
+ version: number;
23
+ updated: number;
24
+ }
25
+
26
+ /**
27
+ * Cached turn index entry for a session.
28
+ */
29
+ interface TurnCacheEntry {
30
+ fingerprint: SessionFingerprint;
31
+ turnMap: Map<string, number>;
32
+ cachedAt: number;
33
+ }
34
+
35
+ const turnCacheTtlMs = 10 * 60 * 1000;
36
+ const turnCacheMaxEntries = 128;
37
+
38
+ /**
39
+ * Logger interface matching the runtime logger shape.
40
+ */
41
+ export interface TurnCacheLogger {
42
+ debug: (message: string, extra?: Record<string, unknown>) => void;
43
+ }
44
+
45
+ /**
46
+ * Callback type for fetching all messages and computing turn items.
47
+ */
48
+ export type FetchTurnItems = () => Promise<TurnComputeItem[]>;
49
+
50
+ /**
51
+ * Callback type for computing turns from items.
52
+ * Matches the signature of computeTurns from domain.ts.
53
+ */
54
+ export type ComputeTurnsFn = (items: TurnComputeItem[]) => Map<string, number>;
55
+
56
+ // =============================================================================
57
+ // Turn Cache Implementation
58
+ // =============================================================================
59
+
60
+ /**
61
+ * In-memory turn cache keyed by session ID.
62
+ */
63
+ const turnCache = new Map<string, TurnCacheEntry>();
64
+
65
+ function pruneTurnCache(now = Date.now()) {
66
+ for (const [sessionId, entry] of turnCache) {
67
+ if (now - entry.cachedAt > turnCacheTtlMs) {
68
+ turnCache.delete(sessionId);
69
+ }
70
+ }
71
+
72
+ while (turnCache.size > turnCacheMaxEntries) {
73
+ const oldest = turnCache.keys().next().value;
74
+ if (oldest === undefined) {
75
+ break;
76
+ }
77
+ turnCache.delete(oldest);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Compare two fingerprints for equality.
83
+ */
84
+ function fingerprintsMatch(a: SessionFingerprint, b: SessionFingerprint): boolean {
85
+ return a.version === b.version && a.updated === b.updated;
86
+ }
87
+
88
+ /**
89
+ * Build a fingerprint from session metadata.
90
+ */
91
+ export function buildFingerprint(version: number, updated: number): SessionFingerprint {
92
+ return { version, updated };
93
+ }
94
+
95
+ /**
96
+ * Get cached turn map if valid, otherwise return undefined.
97
+ *
98
+ * @param sessionId Session ID to look up
99
+ * @param fingerprint Current session fingerprint for validation
100
+ * @returns Cached turn map if valid, undefined if stale/missing
101
+ */
102
+ export function getTurnCache(
103
+ sessionId: string,
104
+ fingerprint: SessionFingerprint,
105
+ ): Map<string, number> | undefined {
106
+ pruneTurnCache();
107
+ const entry = turnCache.get(sessionId);
108
+ if (!entry) {
109
+ return undefined;
110
+ }
111
+
112
+ if (!fingerprintsMatch(entry.fingerprint, fingerprint)) {
113
+ turnCache.delete(sessionId);
114
+ return undefined;
115
+ }
116
+
117
+ // Refresh insertion order to keep hot entries in this bounded cache.
118
+ turnCache.delete(sessionId);
119
+ turnCache.set(sessionId, entry);
120
+
121
+ return entry.turnMap;
122
+ }
123
+
124
+ /**
125
+ * Store turn map in cache.
126
+ *
127
+ * @param sessionId Session ID to cache for
128
+ * @param fingerprint Session fingerprint at time of computation
129
+ * @param turnMap Computed turn map
130
+ */
131
+ export function setTurnCache(
132
+ sessionId: string,
133
+ fingerprint: SessionFingerprint,
134
+ turnMap: Map<string, number>,
135
+ ): void {
136
+ turnCache.delete(sessionId);
137
+ turnCache.set(sessionId, {
138
+ fingerprint,
139
+ turnMap,
140
+ cachedAt: Date.now(),
141
+ });
142
+ pruneTurnCache();
143
+ }
144
+
145
+ /**
146
+ * Clear turn cache for a specific session.
147
+ */
148
+ export function clearTurnCache(sessionId: string): void {
149
+ turnCache.delete(sessionId);
150
+ }
151
+
152
+ // =============================================================================
153
+ // High-Level API
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Result of getTurnMapWithCache operation.
158
+ */
159
+ export interface TurnCacheResult {
160
+ turnMap: Map<string, number>;
161
+ source: "hit" | "rebuild" | "fallback";
162
+ }
163
+
164
+ /**
165
+ * Get turn map with caching support.
166
+ *
167
+ * This is the main entry point for turn cache usage.
168
+ * It handles:
169
+ * - Cache hit: return cached turn map immediately
170
+ * - Cache miss/stale: rebuild from full scan
171
+ * - Fallback: if fingerprint unavailable, always do full scan
172
+ *
173
+ * @param sessionId Session ID for cache key
174
+ * @param fingerprint Current session fingerprint (undefined triggers fallback)
175
+ * @param fetchItems Callback to fetch turn items if rebuild needed
176
+ * @param computeTurnsFn Function to compute turns from items
177
+ * @param logger Optional logger for cache events
178
+ * @returns Turn map and source indicator
179
+ */
180
+ export async function getTurnMapWithCache(
181
+ sessionId: string,
182
+ fingerprint: SessionFingerprint | undefined,
183
+ fetchItems: FetchTurnItems,
184
+ computeTurnsFn: ComputeTurnsFn,
185
+ logger?: TurnCacheLogger,
186
+ ): Promise<TurnCacheResult> {
187
+ // Fallback mode: no fingerprint means no caching possible
188
+ if (fingerprint === undefined) {
189
+ logger?.debug("turn cache fallback (no fingerprint)", { sessionId });
190
+ const items = await fetchItems();
191
+ const turnMap = computeTurnsFn(items);
192
+ return { turnMap, source: "fallback" };
193
+ }
194
+
195
+ // Try cache hit
196
+ const cached = getTurnCache(sessionId, fingerprint);
197
+ if (cached) {
198
+ logger?.debug("turn cache hit", {
199
+ sessionId,
200
+ entries: cached.size,
201
+ });
202
+ return { turnMap: cached, source: "hit" };
203
+ }
204
+
205
+ // Cache miss or stale - rebuild
206
+ logger?.debug("turn cache rebuild", {
207
+ sessionId,
208
+ version: fingerprint.version,
209
+ updated: fingerprint.updated,
210
+ });
211
+
212
+ const items = await fetchItems();
213
+ const turnMap = computeTurnsFn(items);
214
+
215
+ // Store in cache
216
+ setTurnCache(sessionId, fingerprint, turnMap);
217
+
218
+ return { turnMap, source: "rebuild" };
219
+ }