@drawsy/companion 0.0.0-stage → 0.1.28

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,241 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ const emptyPreference = () => ({
5
+ model: null,
6
+ modelProvider: null,
7
+ effort: null,
8
+ accessMode: null,
9
+ internetEnabled: null
10
+ });
11
+ export const defaultConversationPreferences = () => ({
12
+ engine: "codex",
13
+ codex: emptyPreference(),
14
+ opencode: emptyPreference(),
15
+ updatedAt: 0
16
+ });
17
+ const initialStore = () => ({
18
+ version: 1,
19
+ preferences: defaultConversationPreferences(),
20
+ conversations: [],
21
+ rememberedFolders: {}
22
+ });
23
+ const localStateDirectory = () => {
24
+ if (process.env.DRAWSY_LOCAL_STATE_DIR) {
25
+ return path.resolve(process.env.DRAWSY_LOCAL_STATE_DIR);
26
+ }
27
+ if (process.platform === "darwin") {
28
+ return path.join(homedir(), "Library", "Application Support", "Drawsy AI");
29
+ }
30
+ return path.join(process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state"), "drawsy-ai");
31
+ };
32
+ const isAgentEngine = (value) => value === "codex" || value === "opencode";
33
+ const isAccessMode = (value) => value === "workspace" || value === "readOnly";
34
+ const readPreference = (value) => {
35
+ const record = value && typeof value === "object" ? value : {};
36
+ return {
37
+ model: typeof record.model === "string" ? record.model : null,
38
+ modelProvider: typeof record.modelProvider === "string" ? record.modelProvider : null,
39
+ effort: typeof record.effort === "string" ? record.effort : null,
40
+ accessMode: isAccessMode(record.accessMode) ? record.accessMode : null,
41
+ internetEnabled: typeof record.internetEnabled === "boolean" ? record.internetEnabled : null
42
+ };
43
+ };
44
+ const readConversation = (value) => {
45
+ const record = value && typeof value === "object" ? value : null;
46
+ if (!record ||
47
+ typeof record.id !== "string" ||
48
+ (record.scope !== "canvas" && record.scope !== "general") ||
49
+ !isAgentEngine(record.engine) ||
50
+ typeof record.title !== "string" ||
51
+ typeof record.createdAt !== "number" ||
52
+ typeof record.updatedAt !== "number" ||
53
+ typeof record.messageCount !== "number") {
54
+ return null;
55
+ }
56
+ return {
57
+ id: record.id,
58
+ scope: record.scope,
59
+ canvasId: typeof record.canvasId === "string" ? record.canvasId : null,
60
+ canvasName: typeof record.canvasName === "string" ? record.canvasName : null,
61
+ engine: record.engine,
62
+ title: record.title,
63
+ createdAt: record.createdAt,
64
+ updatedAt: record.updatedAt,
65
+ messageCount: Math.max(0, Math.floor(record.messageCount)),
66
+ workspacePath: typeof record.workspacePath === "string" ? record.workspacePath : null,
67
+ codexThreadId: typeof record.codexThreadId === "string" ? record.codexThreadId : null,
68
+ openCodeSessionId: typeof record.openCodeSessionId === "string" ? record.openCodeSessionId : null
69
+ };
70
+ };
71
+ export class LocalConversationStore {
72
+ stateDirectory = localStateDirectory();
73
+ statePath = path.join(this.stateDirectory, "ai-conversations.json");
74
+ data = initialStore();
75
+ loaded;
76
+ writeQueue = Promise.resolve();
77
+ constructor() {
78
+ this.loaded = this.load();
79
+ }
80
+ async load() {
81
+ try {
82
+ const parsed = JSON.parse(await readFile(this.statePath, "utf8"));
83
+ const record = parsed && typeof parsed === "object" ? parsed : null;
84
+ if (!record || record.version !== 1)
85
+ return;
86
+ const preferencesRecord = record.preferences && typeof record.preferences === "object"
87
+ ? record.preferences
88
+ : {};
89
+ const preferences = {
90
+ engine: isAgentEngine(preferencesRecord.engine) ? preferencesRecord.engine : "codex",
91
+ codex: readPreference(preferencesRecord.codex),
92
+ opencode: readPreference(preferencesRecord.opencode),
93
+ updatedAt: typeof preferencesRecord.updatedAt === "number" ? preferencesRecord.updatedAt : 0
94
+ };
95
+ const conversations = Array.isArray(record.conversations)
96
+ ? record.conversations.map(readConversation).filter((item) => !!item)
97
+ : [];
98
+ const rememberedFoldersRecord = record.rememberedFolders && typeof record.rememberedFolders === "object"
99
+ ? record.rememberedFolders
100
+ : {};
101
+ const rememberedFolders = Object.fromEntries(Object.entries(rememberedFoldersRecord)
102
+ .filter(([key, value]) => key.length > 0 &&
103
+ key.length <= 512 &&
104
+ typeof value === "string" &&
105
+ value.length > 0 &&
106
+ value.length <= 4096)
107
+ .slice(-100)
108
+ .map(([key, value]) => [key, value]));
109
+ this.data = { version: 1, preferences, conversations, rememberedFolders };
110
+ }
111
+ catch (error) {
112
+ if (error.code !== "ENOENT") {
113
+ const backupPath = `${this.statePath}.corrupt-${Date.now()}`;
114
+ const preserved = await rename(this.statePath, backupPath)
115
+ .then(() => backupPath)
116
+ .catch(() => null);
117
+ console.warn(preserved
118
+ ? `Drawsy local AI history was unreadable and was preserved at ${preserved}.`
119
+ : "Drawsy local AI history could not be read; starting empty.", error);
120
+ }
121
+ }
122
+ }
123
+ async persist() {
124
+ await mkdir(this.stateDirectory, { recursive: true });
125
+ const temporaryPath = `${this.statePath}.${process.pid}.tmp`;
126
+ await writeFile(temporaryPath, `${JSON.stringify(this.data, null, 2)}\n`, {
127
+ encoding: "utf8",
128
+ mode: 0o600
129
+ });
130
+ await rename(temporaryPath, this.statePath);
131
+ }
132
+ async mutate(change) {
133
+ await this.loaded;
134
+ let value;
135
+ const write = this.writeQueue.then(async () => {
136
+ value = await change();
137
+ await this.persist();
138
+ });
139
+ this.writeQueue = write.catch(() => undefined);
140
+ await write;
141
+ return value;
142
+ }
143
+ async list(scope, canvasId) {
144
+ await this.loaded;
145
+ return this.data.conversations
146
+ .filter((conversation) => conversation.scope === scope &&
147
+ (scope === "general" || conversation.canvasId === canvasId))
148
+ .sort((left, right) => right.updatedAt - left.updatedAt)
149
+ .map((conversation) => ({ ...conversation }));
150
+ }
151
+ async get(id) {
152
+ await this.loaded;
153
+ const conversation = this.data.conversations.find((item) => item.id === id);
154
+ return conversation ? { ...conversation } : null;
155
+ }
156
+ async upsert(input) {
157
+ return this.mutate(() => {
158
+ const existing = this.data.conversations.find((item) => item.id === input.id);
159
+ const workspaceChanged = !!existing && existing.workspacePath !== input.workspacePath;
160
+ const conversation = existing
161
+ ? {
162
+ ...existing,
163
+ ...input,
164
+ createdAt: existing.createdAt,
165
+ title: existing.title === "New conversation" ? input.title : existing.title,
166
+ messageCount: Math.max(existing.messageCount, input.messageCount),
167
+ codexThreadId: workspaceChanged ? null : existing.codexThreadId,
168
+ openCodeSessionId: workspaceChanged ? null : existing.openCodeSessionId,
169
+ updatedAt: Math.max(existing.updatedAt, input.updatedAt)
170
+ }
171
+ : { ...input, codexThreadId: null, openCodeSessionId: null };
172
+ const index = this.data.conversations.findIndex((item) => item.id === input.id);
173
+ if (index >= 0)
174
+ this.data.conversations[index] = conversation;
175
+ else
176
+ this.data.conversations.push(conversation);
177
+ return { ...conversation };
178
+ });
179
+ }
180
+ async getRememberedFolder(key) {
181
+ await this.loaded;
182
+ return this.data.rememberedFolders[key] || null;
183
+ }
184
+ async rememberFolder(key, folderPath) {
185
+ return this.mutate(() => {
186
+ if (!(key in this.data.rememberedFolders) && Object.keys(this.data.rememberedFolders).length >= 100) {
187
+ const oldestKey = Object.keys(this.data.rememberedFolders)[0];
188
+ if (oldestKey)
189
+ delete this.data.rememberedFolders[oldestKey];
190
+ }
191
+ this.data.rememberedFolders[key] = folderPath;
192
+ });
193
+ }
194
+ async forgetRememberedFolder(key) {
195
+ return this.mutate(() => {
196
+ delete this.data.rememberedFolders[key];
197
+ });
198
+ }
199
+ async recordUserMessage(id, message) {
200
+ return this.mutate(() => {
201
+ const conversation = this.data.conversations.find((item) => item.id === id);
202
+ if (!conversation)
203
+ throw new Error("Conversation does not exist locally.");
204
+ const trimmed = message.replace(/\s+/g, " ").trim();
205
+ conversation.updatedAt = Date.now();
206
+ if (conversation.messageCount === 0 && trimmed) {
207
+ conversation.title = trimmed.slice(0, 96);
208
+ }
209
+ conversation.messageCount += 1;
210
+ return { ...conversation };
211
+ });
212
+ }
213
+ async setNativeSession(id, engine, nativeSessionId) {
214
+ return this.mutate(() => {
215
+ const conversation = this.data.conversations.find((item) => item.id === id);
216
+ if (!conversation)
217
+ throw new Error("Conversation does not exist locally.");
218
+ if (engine === "codex")
219
+ conversation.codexThreadId = nativeSessionId;
220
+ else
221
+ conversation.openCodeSessionId = nativeSessionId;
222
+ conversation.updatedAt = Date.now();
223
+ return { ...conversation };
224
+ });
225
+ }
226
+ async getPreferences() {
227
+ await this.loaded;
228
+ return structuredClone(this.data.preferences);
229
+ }
230
+ async setPreferences(input) {
231
+ return this.mutate(() => {
232
+ this.data.preferences = {
233
+ engine: input.engine,
234
+ codex: readPreference(input.codex),
235
+ opencode: readPreference(input.opencode),
236
+ updatedAt: Date.now()
237
+ };
238
+ return structuredClone(this.data.preferences);
239
+ });
240
+ }
241
+ }
@@ -0,0 +1,15 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ export const resolveDrawsyMcpEntry = (moduleUrl) => {
4
+ const entry = path.resolve(path.dirname(fileURLToPath(moduleUrl)), "mcp.js");
5
+ if (!process.versions.electron)
6
+ return entry;
7
+ return entry.replace(`${path.sep}app.asar${path.sep}`, `${path.sep}app.asar.unpacked${path.sep}`);
8
+ };
9
+ export const drawsyMcpProcess = (entry) => ({
10
+ command: process.execPath,
11
+ args: [entry],
12
+ environment: process.versions.electron
13
+ ? { ELECTRON_RUN_AS_NODE: "1" }
14
+ : {}
15
+ });