cursor-opencode-provider 0.4.3 → 0.5.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.
Files changed (37) hide show
  1. package/README.md +17 -12
  2. package/dist/context/build.d.ts +8 -0
  3. package/dist/context/build.js +72 -23
  4. package/dist/context/frozen.d.ts +29 -0
  5. package/dist/context/frozen.js +168 -0
  6. package/dist/context/index.d.ts +2 -1
  7. package/dist/context/index.js +2 -1
  8. package/dist/context/rules.d.ts +1 -0
  9. package/dist/context/rules.js +1 -1
  10. package/dist/language-model.d.ts +10 -9
  11. package/dist/language-model.js +387 -118
  12. package/dist/protocol/blob-reachability.d.ts +15 -0
  13. package/dist/protocol/blob-reachability.js +140 -0
  14. package/dist/protocol/blob-store.d.ts +37 -0
  15. package/dist/protocol/blob-store.js +115 -0
  16. package/dist/protocol/conversation-bind.d.ts +13 -0
  17. package/dist/protocol/conversation-bind.js +27 -1
  18. package/dist/protocol/conversation-persistence.d.ts +32 -0
  19. package/dist/protocol/conversation-persistence.js +504 -0
  20. package/dist/protocol/conversation-state.d.ts +16 -0
  21. package/dist/protocol/conversation-state.js +62 -0
  22. package/dist/protocol/kv.d.ts +1 -0
  23. package/dist/protocol/kv.js +2 -0
  24. package/dist/protocol/messages.d.ts +2 -0
  25. package/dist/protocol/messages.js +30 -2
  26. package/dist/protocol/request.d.ts +2 -0
  27. package/dist/protocol/request.js +2 -0
  28. package/dist/protocol/token-details.d.ts +33 -0
  29. package/dist/protocol/token-details.js +155 -0
  30. package/dist/protocol/tools.d.ts +17 -0
  31. package/dist/protocol/tools.js +76 -0
  32. package/dist/session.d.ts +39 -11
  33. package/dist/shared.d.ts +3 -0
  34. package/dist/shared.js +3 -0
  35. package/dist/usage.d.ts +40 -35
  36. package/dist/usage.js +199 -68
  37. package/package.json +1 -1
@@ -0,0 +1,15 @@
1
+ type ResolveBlob = (id: Uint8Array) => Uint8Array | undefined;
2
+ /**
3
+ * Return the blob ids reachable from Cursor's latest ConversationStateStructure.
4
+ *
5
+ * This mirrors Cursor CLI's conversation-export traversal: turns lead to their
6
+ * user message and steps, shell turns lead to command/output, state-level
7
+ * summaries/todos/prompts are direct refs, and subagent states recurse. Unknown
8
+ * protobuf fields are deliberately ignored for forward compatibility.
9
+ *
10
+ * `resolveBlob` must also resolve Cursor's content-as-id values. Throwing on a
11
+ * missing hash or malformed referenced message makes the caller retain its full
12
+ * snapshot rather than risk publishing an incomplete restart graph.
13
+ */
14
+ export declare function collectReachableConversationBlobIds(checkpoint: Uint8Array, resolveBlob: ResolveBlob): Set<string>;
15
+ export {};
@@ -0,0 +1,140 @@
1
+ import protobuf from "protobufjs";
2
+ const MAX_NESTED_STATE_DEPTH = 64;
3
+ function hex(bytes) {
4
+ let result = "";
5
+ for (let index = 0; index < bytes.length; index++) {
6
+ result += bytes[index].toString(16).padStart(2, "0");
7
+ }
8
+ return result;
9
+ }
10
+ /** Read only length-delimited fields while still validating the complete protobuf. */
11
+ function bytesFields(data) {
12
+ const reader = protobuf.Reader.create(data);
13
+ const fields = [];
14
+ while (reader.pos < reader.len) {
15
+ const tag = reader.uint32();
16
+ const number = tag >>> 3;
17
+ const wireType = tag & 7;
18
+ if (number === 0)
19
+ throw new Error("invalid protobuf field zero");
20
+ if (wireType === 2)
21
+ fields.push({ number, bytes: reader.bytes() });
22
+ else
23
+ reader.skipType(wireType);
24
+ }
25
+ return fields;
26
+ }
27
+ function mapValue(entry) {
28
+ return bytesFields(entry).find((field) => field.number === 2)?.bytes;
29
+ }
30
+ /**
31
+ * Return the blob ids reachable from Cursor's latest ConversationStateStructure.
32
+ *
33
+ * This mirrors Cursor CLI's conversation-export traversal: turns lead to their
34
+ * user message and steps, shell turns lead to command/output, state-level
35
+ * summaries/todos/prompts are direct refs, and subagent states recurse. Unknown
36
+ * protobuf fields are deliberately ignored for forward compatibility.
37
+ *
38
+ * `resolveBlob` must also resolve Cursor's content-as-id values. Throwing on a
39
+ * missing hash or malformed referenced message makes the caller retain its full
40
+ * snapshot rather than risk publishing an incomplete restart graph.
41
+ */
42
+ export function collectReachableConversationBlobIds(checkpoint, resolveBlob) {
43
+ const reachable = new Set();
44
+ const reference = (id) => {
45
+ if (id.length === 0)
46
+ return undefined;
47
+ reachable.add(hex(id));
48
+ const value = resolveBlob(id);
49
+ if (!value)
50
+ throw new Error(`missing referenced blob ${hex(id)}`);
51
+ return value;
52
+ };
53
+ const walkUserMessage = (data) => {
54
+ const selectedContext = bytesFields(data).find((field) => field.number === 3)?.bytes;
55
+ if (!selectedContext)
56
+ return;
57
+ for (const imageField of bytesFields(selectedContext)) {
58
+ if (imageField.number !== 1)
59
+ continue;
60
+ for (const imagePart of bytesFields(imageField.bytes)) {
61
+ if (imagePart.number === 1) {
62
+ reference(imagePart.bytes);
63
+ }
64
+ else if (imagePart.number === 9) {
65
+ const blobId = bytesFields(imagePart.bytes).find((field) => field.number === 1)?.bytes;
66
+ if (blobId)
67
+ reference(blobId);
68
+ }
69
+ }
70
+ }
71
+ };
72
+ const walkTurn = (data) => {
73
+ for (const turnField of bytesFields(data)) {
74
+ if (turnField.number === 1) {
75
+ for (const agentField of bytesFields(turnField.bytes)) {
76
+ if (agentField.number === 1) {
77
+ const userMessage = reference(agentField.bytes);
78
+ if (userMessage)
79
+ walkUserMessage(userMessage);
80
+ }
81
+ else if (agentField.number === 2) {
82
+ reference(agentField.bytes);
83
+ }
84
+ }
85
+ return;
86
+ }
87
+ if (turnField.number === 2) {
88
+ for (const shellField of bytesFields(turnField.bytes)) {
89
+ if (shellField.number === 1 || shellField.number === 2) {
90
+ reference(shellField.bytes);
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ }
96
+ };
97
+ const walkPersistedSubagent = (data, depth) => {
98
+ const nestedState = bytesFields(data).find((field) => field.number === 1)?.bytes;
99
+ if (nestedState)
100
+ walkState(nestedState, depth + 1);
101
+ };
102
+ const walkState = (data, depth) => {
103
+ if (depth > MAX_NESTED_STATE_DEPTH)
104
+ throw new Error("conversation state nesting is too deep");
105
+ for (const field of bytesFields(data)) {
106
+ switch (field.number) {
107
+ case 8: {
108
+ const turn = reference(field.bytes);
109
+ if (turn)
110
+ walkTurn(turn);
111
+ break;
112
+ }
113
+ case 1: // root_prompt_messages_json
114
+ case 3: // todos
115
+ case 6: // summary
116
+ case 7: // plan
117
+ case 13: // summary_archives
118
+ reference(field.bytes);
119
+ break;
120
+ case 16: { // map<string, SubagentPersistedState> subagent_states
121
+ const persisted = mapValue(field.bytes);
122
+ if (persisted)
123
+ walkPersistedSubagent(persisted, depth);
124
+ break;
125
+ }
126
+ case 31: { // map<string, bytes> subagent_state_refs
127
+ const subagentId = mapValue(field.bytes);
128
+ if (!subagentId)
129
+ break;
130
+ const persisted = reference(subagentId);
131
+ if (persisted)
132
+ walkPersistedSubagent(persisted, depth);
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ };
138
+ walkState(checkpoint, 0);
139
+ return reachable;
140
+ }
@@ -6,10 +6,47 @@
6
6
  * fails ("Unexpected token ... is not valid JSON").
7
7
  *
8
8
  * This store is keyed by conversation_id and survives across Run streams.
9
+ * At a successful TurnEnded it is compacted to the graph reachable from the
10
+ * latest checkpoint, matching Cursor CLI's conversation-export traversal.
9
11
  */
10
12
  export declare function setConversationBlob(conversationId: string, blobId: Uint8Array, blobData: Uint8Array): string;
11
13
  export declare function getConversationBlob(conversationId: string, blobId: Uint8Array): Uint8Array | undefined;
12
14
  export declare function conversationBlobCount(conversationId: string): number;
15
+ export type ConversationBlobSnapshot = {
16
+ id: string;
17
+ data: Uint8Array;
18
+ };
19
+ export type ConversationBlobCompaction = {
20
+ blobs: ConversationBlobSnapshot[];
21
+ beforeCount: number;
22
+ beforeBytes: number;
23
+ afterCount: number;
24
+ afterBytes: number;
25
+ compacted: boolean;
26
+ fallbackReason?: string;
27
+ };
28
+ export type ConversationBlobGraphStats = {
29
+ count: number;
30
+ bytes: number;
31
+ complete: boolean;
32
+ fallbackReason?: string;
33
+ };
34
+ /**
35
+ * Measure the checkpoint-reachable blob graph without mutating the store.
36
+ * Incomplete graphs conservatively report the complete retained bucket, since
37
+ * that is what the KV channel may have to serve if the checkpoint is used.
38
+ */
39
+ export declare function inspectConversationBlobGraph(conversationId: string, checkpoint: Uint8Array | undefined): ConversationBlobGraphStats;
40
+ /** Copy all durable blobs so a completed turn can be persisted atomically. */
41
+ export declare function snapshotConversationBlobs(conversationId: string): ConversationBlobSnapshot[];
42
+ /**
43
+ * Keep only blobs reachable from the latest checkpoint and snapshot them.
44
+ * Malformed/unknown state or a missing referenced hash falls back to retaining
45
+ * every blob: cache size must never come at the cost of restart correctness.
46
+ */
47
+ export declare function compactConversationBlobs(conversationId: string, checkpoint: Uint8Array | undefined): ConversationBlobCompaction;
48
+ /** Replace the in-memory blob bucket when restoring a persisted conversation. */
49
+ export declare function restoreConversationBlobs(conversationId: string, blobs: readonly ConversationBlobSnapshot[]): void;
13
50
  /** Drop all blobs for a conversation (compaction conversation reset). */
14
51
  export declare function clearConversationBlobs(conversationId: string): void;
15
52
  /** SHA-256 content hashes are 32 non-text bytes — never echo those as content. */
@@ -6,7 +6,10 @@
6
6
  * fails ("Unexpected token ... is not valid JSON").
7
7
  *
8
8
  * This store is keyed by conversation_id and survives across Run streams.
9
+ * At a successful TurnEnded it is compacted to the graph reachable from the
10
+ * latest checkpoint, matching Cursor CLI's conversation-export traversal.
9
11
  */
12
+ import { collectReachableConversationBlobIds } from "./blob-reachability.js";
10
13
  function hex(b) {
11
14
  let s = "";
12
15
  for (let i = 0; i < b.length; i++)
@@ -14,6 +17,12 @@ function hex(b) {
14
17
  return s;
15
18
  }
16
19
  const byConversation = new Map();
20
+ function retainedBlobStats(current) {
21
+ let bytes = 0;
22
+ for (const data of current.values())
23
+ bytes += data.length;
24
+ return { count: current.size, bytes };
25
+ }
17
26
  function bucket(conversationId) {
18
27
  let m = byConversation.get(conversationId);
19
28
  if (!m) {
@@ -34,6 +43,112 @@ export function getConversationBlob(conversationId, blobId) {
34
43
  export function conversationBlobCount(conversationId) {
35
44
  return byConversation.get(conversationId)?.size ?? 0;
36
45
  }
46
+ /**
47
+ * Measure the checkpoint-reachable blob graph without mutating the store.
48
+ * Incomplete graphs conservatively report the complete retained bucket, since
49
+ * that is what the KV channel may have to serve if the checkpoint is used.
50
+ */
51
+ export function inspectConversationBlobGraph(conversationId, checkpoint) {
52
+ const current = byConversation.get(conversationId) ?? new Map();
53
+ const retainedStats = (fallbackReason) => ({
54
+ ...retainedBlobStats(current),
55
+ complete: fallbackReason === undefined,
56
+ ...(fallbackReason ? { fallbackReason } : {}),
57
+ });
58
+ if (!checkpoint?.length)
59
+ return retainedStats("checkpoint unavailable");
60
+ try {
61
+ const reachable = collectReachableConversationBlobIds(checkpoint, (id) => {
62
+ const stored = current.get(hex(id));
63
+ if (stored)
64
+ return stored;
65
+ return isBlobIdHash(id) ? undefined : id;
66
+ });
67
+ let count = 0;
68
+ let bytes = 0;
69
+ for (const id of reachable) {
70
+ const data = current.get(id);
71
+ if (!data)
72
+ continue;
73
+ count++;
74
+ bytes += data.length;
75
+ }
76
+ return { count, bytes, complete: true };
77
+ }
78
+ catch (error) {
79
+ return retainedStats(error instanceof Error ? error.message : String(error));
80
+ }
81
+ }
82
+ /** Copy all durable blobs so a completed turn can be persisted atomically. */
83
+ export function snapshotConversationBlobs(conversationId) {
84
+ return [...(byConversation.get(conversationId) ?? [])].map(([id, data]) => ({
85
+ id,
86
+ data: Uint8Array.from(data),
87
+ }));
88
+ }
89
+ /**
90
+ * Keep only blobs reachable from the latest checkpoint and snapshot them.
91
+ * Malformed/unknown state or a missing referenced hash falls back to retaining
92
+ * every blob: cache size must never come at the cost of restart correctness.
93
+ */
94
+ export function compactConversationBlobs(conversationId, checkpoint) {
95
+ const current = byConversation.get(conversationId) ?? new Map();
96
+ const before = retainedBlobStats(current);
97
+ const beforeCount = before.count;
98
+ const beforeBytes = before.bytes;
99
+ const fallback = (reason) => ({
100
+ blobs: snapshotConversationBlobs(conversationId),
101
+ beforeCount,
102
+ beforeBytes,
103
+ afterCount: beforeCount,
104
+ afterBytes: beforeBytes,
105
+ compacted: false,
106
+ fallbackReason: reason,
107
+ });
108
+ if (!checkpoint?.length)
109
+ return fallback("checkpoint unavailable");
110
+ try {
111
+ const reachable = collectReachableConversationBlobIds(checkpoint, (id) => {
112
+ const stored = current.get(hex(id));
113
+ if (stored)
114
+ return stored;
115
+ // Cursor occasionally uses short literal content as its own id.
116
+ return isBlobIdHash(id) ? undefined : id;
117
+ });
118
+ const retained = new Map([...current].filter(([id]) => reachable.has(id)));
119
+ if (retained.size > 0)
120
+ byConversation.set(conversationId, retained);
121
+ else
122
+ byConversation.delete(conversationId);
123
+ const afterBytes = [...retained.values()].reduce((total, data) => total + data.length, 0);
124
+ return {
125
+ blobs: snapshotConversationBlobs(conversationId),
126
+ beforeCount,
127
+ beforeBytes,
128
+ afterCount: retained.size,
129
+ afterBytes,
130
+ compacted: true,
131
+ };
132
+ }
133
+ catch (error) {
134
+ return fallback(error instanceof Error ? error.message : String(error));
135
+ }
136
+ }
137
+ /** Replace the in-memory blob bucket when restoring a persisted conversation. */
138
+ export function restoreConversationBlobs(conversationId, blobs) {
139
+ if (!conversationId)
140
+ return;
141
+ const restored = new Map();
142
+ for (const blob of blobs) {
143
+ if (!/^(?:[0-9a-f]{2})+$/i.test(blob.id))
144
+ continue;
145
+ restored.set(blob.id.toLowerCase(), Uint8Array.from(blob.data));
146
+ }
147
+ if (restored.size > 0)
148
+ byConversation.set(conversationId, restored);
149
+ else
150
+ byConversation.delete(conversationId);
151
+ }
37
152
  /** Drop all blobs for a conversation (compaction conversation reset). */
38
153
  export function clearConversationBlobs(conversationId) {
39
154
  byConversation.delete(conversationId);
@@ -1,7 +1,20 @@
1
1
  export declare const MAX_ACTIVE_CONVERSATION_BINDINGS = 256;
2
2
  export declare function resetConversationBindingsForTests(): void;
3
+ export declare function hasConversationBinding(sessionKey: string): boolean;
4
+ export declare function isActiveConversationBinding(sessionKey: string, conversationId: string): boolean;
5
+ /** Restore a validated durable binding before resolving the next Run. */
6
+ export declare function restoreConversationBinding(sessionKey: string, conversationId: string): void;
3
7
  /** Deterministic UUID (version-4 shape) from an arbitrary session key. */
4
8
  export declare function sessionIdToUuid(sessionId: string): string;
9
+ /**
10
+ * Stable Cursor conversation group for one OpenCode session.
11
+ *
12
+ * Individual conversation ids are reminted for compaction/rebase boundaries,
13
+ * while the native Cursor CLI keeps the enclosing agent-store group stable.
14
+ * With no host session identity, the current conversation is the only safe
15
+ * grouping scope available.
16
+ */
17
+ export declare function resolveConversationGroupId(sessionKey: string | undefined, conversationId: string): string;
5
18
  /** Current Cursor conversation_id for an OpenCode session key, creating the default binding if needed. */
6
19
  export declare function peekConversationId(sessionKey: string): string;
7
20
  /**
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { clearCheckpoint } from "./checkpoint.js";
3
3
  import { clearConversationBlobs } from "./blob-store.js";
4
+ import { clearFrozenRequestContext, transferFrozenRequestContext, } from "../context/frozen.js";
4
5
  /**
5
6
  * OpenCode session key → active Cursor conversation_id.
6
7
  *
@@ -22,11 +23,24 @@ function rememberConversation(sessionKey, conversationId) {
22
23
  activeBySession.delete(oldest[0]);
23
24
  clearCheckpoint(oldest[1]);
24
25
  clearConversationBlobs(oldest[1]);
26
+ clearFrozenRequestContext(oldest[1]);
25
27
  }
26
28
  }
27
29
  export function resetConversationBindingsForTests() {
28
30
  activeBySession.clear();
29
31
  }
32
+ export function hasConversationBinding(sessionKey) {
33
+ return activeBySession.has(sessionKey);
34
+ }
35
+ export function isActiveConversationBinding(sessionKey, conversationId) {
36
+ return activeBySession.get(sessionKey) === conversationId;
37
+ }
38
+ /** Restore a validated durable binding before resolving the next Run. */
39
+ export function restoreConversationBinding(sessionKey, conversationId) {
40
+ if (!sessionKey || !conversationId)
41
+ return;
42
+ rememberConversation(sessionKey, conversationId);
43
+ }
30
44
  /** Deterministic UUID (version-4 shape) from an arbitrary session key. */
31
45
  export function sessionIdToUuid(sessionId) {
32
46
  const hash = createHash("sha256").update(`cursor-opencode-provider:conv:${sessionId}`).digest();
@@ -36,6 +50,17 @@ export function sessionIdToUuid(sessionId) {
36
50
  const hex = bytes.toString("hex");
37
51
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
38
52
  }
53
+ /**
54
+ * Stable Cursor conversation group for one OpenCode session.
55
+ *
56
+ * Individual conversation ids are reminted for compaction/rebase boundaries,
57
+ * while the native Cursor CLI keeps the enclosing agent-store group stable.
58
+ * With no host session identity, the current conversation is the only safe
59
+ * grouping scope available.
60
+ */
61
+ export function resolveConversationGroupId(sessionKey, conversationId) {
62
+ return sessionKey ? sessionIdToUuid(sessionKey) : conversationId;
63
+ }
39
64
  /** Current Cursor conversation_id for an OpenCode session key, creating the default binding if needed. */
40
65
  export function peekConversationId(sessionKey) {
41
66
  let id = activeBySession.get(sessionKey);
@@ -56,9 +81,10 @@ export function bindConversationId(sessionKey, opts) {
56
81
  }
57
82
  if (opts?.reset) {
58
83
  const previousId = activeBySession.get(sessionKey) ?? sessionIdToUuid(sessionKey);
84
+ const conversationId = crypto.randomUUID();
59
85
  clearCheckpoint(previousId);
60
86
  clearConversationBlobs(previousId);
61
- const conversationId = crypto.randomUUID();
87
+ transferFrozenRequestContext(previousId, conversationId);
62
88
  rememberConversation(sessionKey, conversationId);
63
89
  return { conversationId, reset: true, previousId };
64
90
  }
@@ -0,0 +1,32 @@
1
+ import type { ConversationBlobSnapshot } from "./blob-store.js";
2
+ import type { OpencodeToolDef } from "./tools.js";
3
+ export type PersistedConversation = {
4
+ sessionKey: string;
5
+ conversationId: string;
6
+ updatedAt: number;
7
+ checkpoint?: Uint8Array;
8
+ blobs: ConversationBlobSnapshot[];
9
+ requestContext: Record<string, unknown>;
10
+ toolCatalog: OpencodeToolDef[];
11
+ postCompactionRebase: boolean;
12
+ };
13
+ export type ConversationLoadStatus = "restored" | "missing" | "invalid" | "expired";
14
+ export type PersistedConversationLoad = {
15
+ status: ConversationLoadStatus;
16
+ value?: PersistedConversation;
17
+ };
18
+ export declare function conversationCacheDirectoryPath(cacheDir: string): string;
19
+ export declare function conversationCacheFilePath(cacheDir: string, sessionKey: string): string;
20
+ /** Create the per-session cache directory and prune records older than 24 hours once per process. */
21
+ export declare function initializeConversationPersistence(cacheDir: string, now?: number): Promise<void>;
22
+ export declare function getPersistedConversation(cacheDir: string, sessionKey: string, now?: number): Promise<PersistedConversation | undefined>;
23
+ /** Read a snapshot together with the reason restart hydration did not restore it. */
24
+ export declare function loadPersistedConversation(cacheDir: string, sessionKey: string, now?: number): Promise<PersistedConversationLoad>;
25
+ /** Replace one session's durable snapshot and refresh its 24-hour lease. */
26
+ export declare function persistConversation(cacheDir: string, value: Omit<PersistedConversation, "updatedAt" | "postCompactionRebase" | "toolCatalog"> & {
27
+ postCompactionRebase?: boolean;
28
+ toolCatalog?: OpencodeToolDef[];
29
+ }, now?: number): Promise<void>;
30
+ /** Delete only the expected binding so an overlapping newer turn cannot be removed. */
31
+ export declare function deletePersistedConversation(cacheDir: string, sessionKey: string, expectedConversationId?: string): Promise<void>;
32
+ export declare function resetConversationPersistenceForTests(): void;