open-think 0.2.4 → 0.3.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.
- package/LICENSE +21 -0
- package/dist/chunk-DCTG6IK4.js +71 -0
- package/dist/chunk-MSOBQE64.js +289 -0
- package/dist/{chunk-N4VAGRBF.js → chunk-ZKUJ5M2W.js} +48 -86
- package/dist/{git-R4CVMKV7.js → git-TG6OJFBT.js} +2 -1
- package/dist/index.js +122 -297
- package/dist/memory-queries-IPGGUAQW.js +30 -0
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 OpenThinkAi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
|
|
3
|
+
// src/lib/paths.ts
|
|
4
|
+
import path from "path";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
function getHome() {
|
|
7
|
+
const home = process.env.HOME;
|
|
8
|
+
if (!home) {
|
|
9
|
+
throw new Error("HOME environment variable is not set");
|
|
10
|
+
}
|
|
11
|
+
return home;
|
|
12
|
+
}
|
|
13
|
+
function sanitizeName(name) {
|
|
14
|
+
if (!name || /[\/\\\.]{2}/.test(name) || /[^a-zA-Z0-9_-]/.test(name)) {
|
|
15
|
+
throw new Error(`Invalid cortex name: "${name}". Use only alphanumeric characters, hyphens, and underscores.`);
|
|
16
|
+
}
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
function getThinkHome() {
|
|
20
|
+
const thinkHome = process.env.THINK_HOME;
|
|
21
|
+
if (thinkHome === void 0 || thinkHome === "") return null;
|
|
22
|
+
return thinkHome;
|
|
23
|
+
}
|
|
24
|
+
function getThinkDir() {
|
|
25
|
+
return getThinkHome() ?? path.join(getHome(), ".think");
|
|
26
|
+
}
|
|
27
|
+
function getThinkConfigDir() {
|
|
28
|
+
const thinkHome = getThinkHome();
|
|
29
|
+
if (thinkHome) return path.join(thinkHome, "config");
|
|
30
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(getHome(), ".config");
|
|
31
|
+
return path.join(xdgConfig, "think");
|
|
32
|
+
}
|
|
33
|
+
function getThinkDataDir() {
|
|
34
|
+
const thinkHome = getThinkHome();
|
|
35
|
+
if (thinkHome) return path.join(thinkHome, "data");
|
|
36
|
+
const xdgData = process.env.XDG_DATA_HOME || path.join(getHome(), ".local", "share");
|
|
37
|
+
return path.join(xdgData, "think");
|
|
38
|
+
}
|
|
39
|
+
function getEngramsDir() {
|
|
40
|
+
return path.join(getThinkDir(), "engrams");
|
|
41
|
+
}
|
|
42
|
+
function getEngramDbPath(cortexName) {
|
|
43
|
+
return path.join(getEngramsDir(), `${sanitizeName(cortexName)}.db`);
|
|
44
|
+
}
|
|
45
|
+
function getRepoPath() {
|
|
46
|
+
return path.join(getThinkDir(), "repo");
|
|
47
|
+
}
|
|
48
|
+
function getLongtermDir() {
|
|
49
|
+
return path.join(getThinkDir(), "longterm");
|
|
50
|
+
}
|
|
51
|
+
function getLongtermPath(cortexName) {
|
|
52
|
+
return path.join(getLongtermDir(), `${sanitizeName(cortexName)}.md`);
|
|
53
|
+
}
|
|
54
|
+
function getCuratorMdPath() {
|
|
55
|
+
return path.join(getThinkDir(), "curator.md");
|
|
56
|
+
}
|
|
57
|
+
function ensureThinkDirs() {
|
|
58
|
+
fs.mkdirSync(getEngramsDir(), { recursive: true });
|
|
59
|
+
fs.mkdirSync(getLongtermDir(), { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
getThinkConfigDir,
|
|
64
|
+
getThinkDataDir,
|
|
65
|
+
getEngramsDir,
|
|
66
|
+
getEngramDbPath,
|
|
67
|
+
getRepoPath,
|
|
68
|
+
getLongtermPath,
|
|
69
|
+
getCuratorMdPath,
|
|
70
|
+
ensureThinkDirs
|
|
71
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
import {
|
|
3
|
+
ensureThinkDirs,
|
|
4
|
+
getEngramDbPath
|
|
5
|
+
} from "./chunk-DCTG6IK4.js";
|
|
6
|
+
|
|
7
|
+
// src/db/memory-queries.ts
|
|
8
|
+
import { v7 as uuidv7 } from "uuid";
|
|
9
|
+
|
|
10
|
+
// src/db/engrams.ts
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
|
|
13
|
+
// src/db/migrate.ts
|
|
14
|
+
function runMigrations(db, migrations2) {
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
17
|
+
version INTEGER PRIMARY KEY NOT NULL,
|
|
18
|
+
applied_at TEXT NOT NULL
|
|
19
|
+
) STRICT;
|
|
20
|
+
`);
|
|
21
|
+
const currentVersion = db.prepare(
|
|
22
|
+
"SELECT COALESCE(MAX(version), 0) as version FROM _migrations"
|
|
23
|
+
).get();
|
|
24
|
+
const pending = migrations2.filter((m) => m.version > currentVersion.version).sort((a, b) => a.version - b.version);
|
|
25
|
+
for (const migration of pending) {
|
|
26
|
+
db.exec("BEGIN");
|
|
27
|
+
try {
|
|
28
|
+
migration.up(db);
|
|
29
|
+
db.prepare("INSERT INTO _migrations (version, applied_at) VALUES (?, ?)").run(
|
|
30
|
+
migration.version,
|
|
31
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
32
|
+
);
|
|
33
|
+
db.exec("COMMIT");
|
|
34
|
+
} catch (err) {
|
|
35
|
+
db.exec("ROLLBACK");
|
|
36
|
+
throw new Error(`Migration v${migration.version} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/db/engrams.ts
|
|
42
|
+
var dbs = /* @__PURE__ */ new Map();
|
|
43
|
+
var migrations = [
|
|
44
|
+
{
|
|
45
|
+
version: 1,
|
|
46
|
+
up: (db) => {
|
|
47
|
+
db.exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS engrams (
|
|
49
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
50
|
+
content TEXT NOT NULL,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
expires_at TEXT NOT NULL,
|
|
53
|
+
evaluated_at TEXT,
|
|
54
|
+
promoted INTEGER,
|
|
55
|
+
deleted_at TEXT
|
|
56
|
+
) STRICT;
|
|
57
|
+
`);
|
|
58
|
+
db.exec(`
|
|
59
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts
|
|
60
|
+
USING fts5(content, content='engrams', content_rowid='rowid');
|
|
61
|
+
`);
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
|
|
64
|
+
INSERT INTO engrams_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
65
|
+
END;
|
|
66
|
+
`);
|
|
67
|
+
db.exec(`
|
|
68
|
+
CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
|
|
69
|
+
INSERT INTO engrams_fts(engrams_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
70
|
+
END;
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
version: 2,
|
|
76
|
+
up: (db) => {
|
|
77
|
+
db.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
79
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
80
|
+
ts TEXT NOT NULL,
|
|
81
|
+
author TEXT NOT NULL,
|
|
82
|
+
content TEXT NOT NULL,
|
|
83
|
+
source_ids TEXT NOT NULL DEFAULT '[]',
|
|
84
|
+
created_at TEXT NOT NULL,
|
|
85
|
+
deleted_at TEXT,
|
|
86
|
+
sync_version INTEGER NOT NULL DEFAULT 0
|
|
87
|
+
) STRICT;
|
|
88
|
+
`);
|
|
89
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_ts ON memories(ts);");
|
|
90
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_sync_version ON memories(sync_version);");
|
|
91
|
+
db.exec(`
|
|
92
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
93
|
+
USING fts5(content, content='memories', content_rowid='rowid');
|
|
94
|
+
`);
|
|
95
|
+
db.exec(`
|
|
96
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
97
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
98
|
+
END;
|
|
99
|
+
`);
|
|
100
|
+
db.exec(`
|
|
101
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
102
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
103
|
+
END;
|
|
104
|
+
`);
|
|
105
|
+
db.exec(`
|
|
106
|
+
CREATE TABLE IF NOT EXISTS longterm_summary (
|
|
107
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
108
|
+
content TEXT NOT NULL,
|
|
109
|
+
updated_at TEXT NOT NULL,
|
|
110
|
+
sync_version INTEGER NOT NULL DEFAULT 0
|
|
111
|
+
) STRICT;
|
|
112
|
+
`);
|
|
113
|
+
db.exec(`
|
|
114
|
+
CREATE TABLE IF NOT EXISTS sync_cursors (
|
|
115
|
+
backend TEXT NOT NULL,
|
|
116
|
+
direction TEXT NOT NULL,
|
|
117
|
+
cursor_value TEXT NOT NULL,
|
|
118
|
+
updated_at TEXT NOT NULL,
|
|
119
|
+
PRIMARY KEY (backend, direction)
|
|
120
|
+
) STRICT;
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
version: 3,
|
|
126
|
+
up: (db) => {
|
|
127
|
+
db.exec("ALTER TABLE engrams ADD COLUMN episode_key TEXT;");
|
|
128
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_engrams_episode_key ON engrams(episode_key);");
|
|
129
|
+
db.exec("ALTER TABLE memories ADD COLUMN episode_key TEXT;");
|
|
130
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_episode_key ON memories(episode_key);");
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
version: 4,
|
|
135
|
+
up: (db) => {
|
|
136
|
+
db.exec("ALTER TABLE engrams ADD COLUMN context TEXT;");
|
|
137
|
+
db.exec("ALTER TABLE engrams ADD COLUMN decisions TEXT;");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
];
|
|
141
|
+
function getCortexDb(cortexName) {
|
|
142
|
+
const cached = dbs.get(cortexName);
|
|
143
|
+
if (cached) return cached;
|
|
144
|
+
ensureThinkDirs();
|
|
145
|
+
const dbPath = getEngramDbPath(cortexName);
|
|
146
|
+
const db = new DatabaseSync(dbPath);
|
|
147
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
148
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
149
|
+
runMigrations(db, migrations);
|
|
150
|
+
dbs.set(cortexName, db);
|
|
151
|
+
return db;
|
|
152
|
+
}
|
|
153
|
+
function closeCortexDb(cortexName) {
|
|
154
|
+
const db = dbs.get(cortexName);
|
|
155
|
+
if (db) {
|
|
156
|
+
db.close();
|
|
157
|
+
dbs.delete(cortexName);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/db/memory-queries.ts
|
|
162
|
+
function insertMemory(cortexName, params) {
|
|
163
|
+
const db = getCortexDb(cortexName);
|
|
164
|
+
const id = params.id ?? uuidv7();
|
|
165
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
166
|
+
const sourceIds = JSON.stringify(params.source_ids ?? []);
|
|
167
|
+
const episodeKey = params.episode_key ?? null;
|
|
168
|
+
db.prepare(
|
|
169
|
+
`INSERT INTO memories (id, ts, author, content, source_ids, created_at, deleted_at, sync_version, episode_key)
|
|
170
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories), ?)`
|
|
171
|
+
).run(id, params.ts, params.author, params.content, sourceIds, now, params.deleted_at ?? null, episodeKey);
|
|
172
|
+
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
173
|
+
return row;
|
|
174
|
+
}
|
|
175
|
+
function insertMemoryIfNotExists(cortexName, params) {
|
|
176
|
+
const db = getCortexDb(cortexName);
|
|
177
|
+
const existing = db.prepare("SELECT id FROM memories WHERE id = ?").get(params.id);
|
|
178
|
+
if (existing) return false;
|
|
179
|
+
insertMemory(cortexName, params);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
function getMemories(cortexName, params = {}) {
|
|
183
|
+
const db = getCortexDb(cortexName);
|
|
184
|
+
const conditions = ["deleted_at IS NULL"];
|
|
185
|
+
const values = [];
|
|
186
|
+
if (params.since) {
|
|
187
|
+
conditions.push("ts >= ?");
|
|
188
|
+
values.push(params.since);
|
|
189
|
+
}
|
|
190
|
+
if (params.until) {
|
|
191
|
+
conditions.push("ts <= ?");
|
|
192
|
+
values.push(params.until);
|
|
193
|
+
}
|
|
194
|
+
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
195
|
+
if (params.limit) {
|
|
196
|
+
values.push(params.limit);
|
|
197
|
+
return db.prepare(
|
|
198
|
+
`SELECT * FROM memories ${where} ORDER BY ts ASC LIMIT ?`
|
|
199
|
+
).all(...values);
|
|
200
|
+
}
|
|
201
|
+
return db.prepare(
|
|
202
|
+
`SELECT * FROM memories ${where} ORDER BY ts ASC`
|
|
203
|
+
).all(...values);
|
|
204
|
+
}
|
|
205
|
+
function getMemoriesBySyncVersion(cortexName, sinceVersion) {
|
|
206
|
+
const db = getCortexDb(cortexName);
|
|
207
|
+
return db.prepare(
|
|
208
|
+
"SELECT * FROM memories WHERE sync_version > ? ORDER BY sync_version ASC"
|
|
209
|
+
).all(sinceVersion);
|
|
210
|
+
}
|
|
211
|
+
function searchMemories(cortexName, query, limit = 20) {
|
|
212
|
+
const db = getCortexDb(cortexName);
|
|
213
|
+
try {
|
|
214
|
+
return db.prepare(
|
|
215
|
+
`SELECT m.* FROM memories m JOIN memories_fts f ON m.rowid = f.rowid
|
|
216
|
+
WHERE memories_fts MATCH ? AND m.deleted_at IS NULL
|
|
217
|
+
ORDER BY rank LIMIT ?`
|
|
218
|
+
).all(query, limit);
|
|
219
|
+
} catch {
|
|
220
|
+
const pattern = `%${query.replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
|
|
221
|
+
return db.prepare(
|
|
222
|
+
`SELECT * FROM memories WHERE content LIKE ? ESCAPE '\\' AND deleted_at IS NULL ORDER BY ts DESC LIMIT ?`
|
|
223
|
+
).all(pattern, limit);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function tombstoneMemory(cortexName, id) {
|
|
227
|
+
const db = getCortexDb(cortexName);
|
|
228
|
+
db.prepare(
|
|
229
|
+
`UPDATE memories SET deleted_at = ?, sync_version = (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories)
|
|
230
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
231
|
+
).run((/* @__PURE__ */ new Date()).toISOString(), id);
|
|
232
|
+
}
|
|
233
|
+
function getLongtermSummary(cortexName) {
|
|
234
|
+
const db = getCortexDb(cortexName);
|
|
235
|
+
const row = db.prepare("SELECT content FROM longterm_summary WHERE id = 1").get();
|
|
236
|
+
return row?.content ?? null;
|
|
237
|
+
}
|
|
238
|
+
function setLongtermSummary(cortexName, content) {
|
|
239
|
+
const db = getCortexDb(cortexName);
|
|
240
|
+
db.prepare(
|
|
241
|
+
`INSERT INTO longterm_summary (id, content, updated_at, sync_version)
|
|
242
|
+
VALUES (1, ?, ?, (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories))
|
|
243
|
+
ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at, sync_version = excluded.sync_version`
|
|
244
|
+
).run(content, (/* @__PURE__ */ new Date()).toISOString());
|
|
245
|
+
}
|
|
246
|
+
function getSyncCursor(cortexName, backend, direction) {
|
|
247
|
+
const db = getCortexDb(cortexName);
|
|
248
|
+
const row = db.prepare(
|
|
249
|
+
"SELECT cursor_value FROM sync_cursors WHERE backend = ? AND direction = ?"
|
|
250
|
+
).get(backend, direction);
|
|
251
|
+
return row?.cursor_value ?? null;
|
|
252
|
+
}
|
|
253
|
+
function setSyncCursor(cortexName, backend, direction, cursorValue) {
|
|
254
|
+
const db = getCortexDb(cortexName);
|
|
255
|
+
db.prepare(
|
|
256
|
+
`INSERT INTO sync_cursors (backend, direction, cursor_value, updated_at)
|
|
257
|
+
VALUES (?, ?, ?, ?)
|
|
258
|
+
ON CONFLICT(backend, direction) DO UPDATE SET cursor_value = excluded.cursor_value, updated_at = excluded.updated_at`
|
|
259
|
+
).run(backend, direction, cursorValue, (/* @__PURE__ */ new Date()).toISOString());
|
|
260
|
+
}
|
|
261
|
+
function getMemoryCount(cortexName) {
|
|
262
|
+
const db = getCortexDb(cortexName);
|
|
263
|
+
const row = db.prepare("SELECT COUNT(*) as count FROM memories WHERE deleted_at IS NULL").get();
|
|
264
|
+
return row.count;
|
|
265
|
+
}
|
|
266
|
+
function getMemoryByEpisodeKey(cortexName, episodeKey) {
|
|
267
|
+
const db = getCortexDb(cortexName);
|
|
268
|
+
const row = db.prepare(
|
|
269
|
+
"SELECT * FROM memories WHERE episode_key = ? AND deleted_at IS NULL LIMIT 1"
|
|
270
|
+
).get(episodeKey);
|
|
271
|
+
return row ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export {
|
|
275
|
+
getCortexDb,
|
|
276
|
+
closeCortexDb,
|
|
277
|
+
insertMemory,
|
|
278
|
+
insertMemoryIfNotExists,
|
|
279
|
+
getMemories,
|
|
280
|
+
getMemoriesBySyncVersion,
|
|
281
|
+
searchMemories,
|
|
282
|
+
tombstoneMemory,
|
|
283
|
+
getLongtermSummary,
|
|
284
|
+
setLongtermSummary,
|
|
285
|
+
getSyncCursor,
|
|
286
|
+
setSyncCursor,
|
|
287
|
+
getMemoryCount,
|
|
288
|
+
getMemoryByEpisodeKey
|
|
289
|
+
};
|
|
@@ -1,88 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
import {
|
|
3
|
+
getRepoPath,
|
|
4
|
+
getThinkConfigDir
|
|
5
|
+
} from "./chunk-DCTG6IK4.js";
|
|
2
6
|
|
|
3
7
|
// src/lib/git.ts
|
|
4
8
|
import { execFileSync } from "child_process";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
9
|
+
import fs2 from "fs";
|
|
10
|
+
import path2 from "path";
|
|
7
11
|
|
|
8
|
-
// src/lib/
|
|
12
|
+
// src/lib/config.ts
|
|
9
13
|
import path from "path";
|
|
10
14
|
import fs from "fs";
|
|
11
|
-
function getHome() {
|
|
12
|
-
const home = process.env.HOME;
|
|
13
|
-
if (!home) {
|
|
14
|
-
throw new Error("HOME environment variable is not set");
|
|
15
|
-
}
|
|
16
|
-
return home;
|
|
17
|
-
}
|
|
18
|
-
function sanitizeName(name) {
|
|
19
|
-
if (!name || /[\/\\\.]{2}/.test(name) || /[^a-zA-Z0-9_-]/.test(name)) {
|
|
20
|
-
throw new Error(`Invalid cortex name: "${name}". Use only alphanumeric characters, hyphens, and underscores.`);
|
|
21
|
-
}
|
|
22
|
-
return name;
|
|
23
|
-
}
|
|
24
|
-
function getThinkHome() {
|
|
25
|
-
const thinkHome = process.env.THINK_HOME;
|
|
26
|
-
if (thinkHome === void 0 || thinkHome === "") return null;
|
|
27
|
-
return thinkHome;
|
|
28
|
-
}
|
|
29
|
-
function getThinkDir() {
|
|
30
|
-
return getThinkHome() ?? path.join(getHome(), ".think");
|
|
31
|
-
}
|
|
32
|
-
function getThinkConfigDir() {
|
|
33
|
-
const thinkHome = getThinkHome();
|
|
34
|
-
if (thinkHome) return path.join(thinkHome, "config");
|
|
35
|
-
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(getHome(), ".config");
|
|
36
|
-
return path.join(xdgConfig, "think");
|
|
37
|
-
}
|
|
38
|
-
function getThinkDataDir() {
|
|
39
|
-
const thinkHome = getThinkHome();
|
|
40
|
-
if (thinkHome) return path.join(thinkHome, "data");
|
|
41
|
-
const xdgData = process.env.XDG_DATA_HOME || path.join(getHome(), ".local", "share");
|
|
42
|
-
return path.join(xdgData, "think");
|
|
43
|
-
}
|
|
44
|
-
function getEngramsDir() {
|
|
45
|
-
return path.join(getThinkDir(), "engrams");
|
|
46
|
-
}
|
|
47
|
-
function getEngramDbPath(cortexName) {
|
|
48
|
-
return path.join(getEngramsDir(), `${sanitizeName(cortexName)}.db`);
|
|
49
|
-
}
|
|
50
|
-
function getRepoPath() {
|
|
51
|
-
return path.join(getThinkDir(), "repo");
|
|
52
|
-
}
|
|
53
|
-
function getLongtermDir() {
|
|
54
|
-
return path.join(getThinkDir(), "longterm");
|
|
55
|
-
}
|
|
56
|
-
function getLongtermPath(cortexName) {
|
|
57
|
-
return path.join(getLongtermDir(), `${sanitizeName(cortexName)}.md`);
|
|
58
|
-
}
|
|
59
|
-
function getCuratorMdPath() {
|
|
60
|
-
return path.join(getThinkDir(), "curator.md");
|
|
61
|
-
}
|
|
62
|
-
function ensureThinkDirs() {
|
|
63
|
-
fs.mkdirSync(getEngramsDir(), { recursive: true });
|
|
64
|
-
fs.mkdirSync(getLongtermDir(), { recursive: true });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// src/lib/config.ts
|
|
68
|
-
import path2 from "path";
|
|
69
|
-
import fs2 from "fs";
|
|
70
15
|
import { v4 as uuidv4 } from "uuid";
|
|
71
16
|
function getConfigDir() {
|
|
72
17
|
return getThinkConfigDir();
|
|
73
18
|
}
|
|
74
19
|
function configPath() {
|
|
75
|
-
return
|
|
20
|
+
return path.join(getConfigDir(), "config.json");
|
|
76
21
|
}
|
|
77
22
|
function saveConfig(config) {
|
|
78
23
|
const dir = getConfigDir();
|
|
79
|
-
|
|
80
|
-
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
25
|
+
fs.writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 384 });
|
|
81
26
|
}
|
|
82
27
|
function getConfig() {
|
|
83
28
|
const fp = configPath();
|
|
84
|
-
if (
|
|
85
|
-
const raw =
|
|
29
|
+
if (fs.existsSync(fp)) {
|
|
30
|
+
const raw = fs.readFileSync(fp, "utf-8");
|
|
86
31
|
return JSON.parse(raw);
|
|
87
32
|
}
|
|
88
33
|
const config = {
|
|
@@ -94,12 +39,34 @@ function getConfig() {
|
|
|
94
39
|
}
|
|
95
40
|
|
|
96
41
|
// src/lib/git.ts
|
|
42
|
+
function safeGitEnv() {
|
|
43
|
+
const env = { ...process.env };
|
|
44
|
+
delete env.GIT_SSH_COMMAND;
|
|
45
|
+
delete env.GIT_PROXY_COMMAND;
|
|
46
|
+
delete env.GIT_ASKPASS;
|
|
47
|
+
delete env.GIT_CONFIG_GLOBAL;
|
|
48
|
+
delete env.GIT_CONFIG_SYSTEM;
|
|
49
|
+
delete env.GIT_WORK_TREE;
|
|
50
|
+
delete env.GIT_DIR;
|
|
51
|
+
delete env.GIT_EXEC_PATH;
|
|
52
|
+
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
53
|
+
env.GIT_TEMPLATE_DIR = "";
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
97
56
|
function runGit(args, cwd) {
|
|
98
57
|
const repoPath = cwd ?? getRepoPath();
|
|
99
|
-
|
|
58
|
+
const safeArgs = [
|
|
59
|
+
"-c",
|
|
60
|
+
"core.hooksPath=/dev/null",
|
|
61
|
+
"-c",
|
|
62
|
+
"core.fsmonitor=",
|
|
63
|
+
...args
|
|
64
|
+
];
|
|
65
|
+
return execFileSync("git", safeArgs, {
|
|
100
66
|
cwd: repoPath,
|
|
101
67
|
encoding: "utf-8",
|
|
102
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
68
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
69
|
+
env: safeGitEnv()
|
|
103
70
|
}).trim();
|
|
104
71
|
}
|
|
105
72
|
function ensureRepoCloned() {
|
|
@@ -108,17 +75,18 @@ function ensureRepoCloned() {
|
|
|
108
75
|
throw new Error("No cortex repo configured. Run: think cortex setup");
|
|
109
76
|
}
|
|
110
77
|
const repoPath = getRepoPath();
|
|
111
|
-
if (
|
|
78
|
+
if (fs2.existsSync(path2.join(repoPath, ".git"))) {
|
|
112
79
|
const remote = runGit(["remote", "get-url", "origin"], repoPath);
|
|
113
80
|
if (remote !== config.cortex.repo) {
|
|
114
81
|
throw new Error(`Repo at ${repoPath} points to ${remote}, expected ${config.cortex.repo}`);
|
|
115
82
|
}
|
|
116
83
|
return;
|
|
117
84
|
}
|
|
118
|
-
|
|
119
|
-
execFileSync("git", ["clone", "--no-checkout", config.cortex.repo, repoPath], {
|
|
85
|
+
fs2.mkdirSync(repoPath, { recursive: true });
|
|
86
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=", "clone", "--no-checkout", config.cortex.repo, repoPath], {
|
|
120
87
|
encoding: "utf-8",
|
|
121
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
88
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
89
|
+
env: safeGitEnv()
|
|
122
90
|
});
|
|
123
91
|
}
|
|
124
92
|
function branchExists(branchName) {
|
|
@@ -136,7 +104,7 @@ function createOrphanBranch(branchName) {
|
|
|
136
104
|
} catch {
|
|
137
105
|
}
|
|
138
106
|
const repoPath = getRepoPath();
|
|
139
|
-
|
|
107
|
+
fs2.writeFileSync(path2.join(repoPath, "000001.jsonl"), "", "utf-8");
|
|
140
108
|
runGit(["add", "000001.jsonl"]);
|
|
141
109
|
runGit(["commit", "-m", `init: create cortex ${branchName}`]);
|
|
142
110
|
runGit(["push", "--set-upstream", "origin", branchName]);
|
|
@@ -153,7 +121,7 @@ function readFileFromBranch(branchName, filePath) {
|
|
|
153
121
|
}
|
|
154
122
|
function appendAndCommit(branchName, newLines, commitMessage, maxRetries = 3, targetFile = "memories.jsonl") {
|
|
155
123
|
const repoPath = getRepoPath();
|
|
156
|
-
const filePath =
|
|
124
|
+
const filePath = path2.join(repoPath, targetFile);
|
|
157
125
|
try {
|
|
158
126
|
runGit(["switch", branchName]);
|
|
159
127
|
} catch {
|
|
@@ -172,7 +140,7 @@ function appendAndCommit(branchName, newLines, commitMessage, maxRetries = 3, ta
|
|
|
172
140
|
}
|
|
173
141
|
}
|
|
174
142
|
const content = newLines.join("\n") + "\n";
|
|
175
|
-
|
|
143
|
+
fs2.appendFileSync(filePath, content, "utf-8");
|
|
176
144
|
runGit(["add", targetFile]);
|
|
177
145
|
runGit(["commit", "-m", commitMessage]);
|
|
178
146
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
@@ -222,11 +190,11 @@ function migrateToBuckets(branchName) {
|
|
|
222
190
|
runGit(["pull", "--rebase", "origin", branchName]);
|
|
223
191
|
} catch {
|
|
224
192
|
}
|
|
225
|
-
const legacyPath =
|
|
226
|
-
const bucketPath =
|
|
227
|
-
if (
|
|
193
|
+
const legacyPath = path2.join(repoPath, "memories.jsonl");
|
|
194
|
+
const bucketPath = path2.join(repoPath, "000001.jsonl");
|
|
195
|
+
if (fs2.existsSync(legacyPath) && !fs2.existsSync(bucketPath)) {
|
|
228
196
|
const preMigrationRef = runGit(["rev-parse", "HEAD"]);
|
|
229
|
-
|
|
197
|
+
fs2.renameSync(legacyPath, bucketPath);
|
|
230
198
|
runGit(["add", "-A"]);
|
|
231
199
|
runGit(["commit", "-m", "migrate: memories.jsonl -> 000001.jsonl"]);
|
|
232
200
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
@@ -248,12 +216,6 @@ function migrateToBuckets(branchName) {
|
|
|
248
216
|
}
|
|
249
217
|
|
|
250
218
|
export {
|
|
251
|
-
getThinkDataDir,
|
|
252
|
-
getEngramsDir,
|
|
253
|
-
getEngramDbPath,
|
|
254
|
-
getLongtermPath,
|
|
255
|
-
getCuratorMdPath,
|
|
256
|
-
ensureThinkDirs,
|
|
257
219
|
getConfigDir,
|
|
258
220
|
saveConfig,
|
|
259
221
|
getConfig,
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
import {
|
|
3
|
+
closeCortexDb,
|
|
4
|
+
getCortexDb,
|
|
5
|
+
getLongtermSummary,
|
|
6
|
+
getMemories,
|
|
7
|
+
getMemoriesBySyncVersion,
|
|
8
|
+
getMemoryByEpisodeKey,
|
|
9
|
+
getMemoryCount,
|
|
10
|
+
getSyncCursor,
|
|
11
|
+
insertMemory,
|
|
12
|
+
insertMemoryIfNotExists,
|
|
13
|
+
searchMemories,
|
|
14
|
+
setLongtermSummary,
|
|
15
|
+
setSyncCursor,
|
|
16
|
+
tombstoneMemory
|
|
17
|
+
} from "./chunk-MSOBQE64.js";
|
|
2
18
|
import {
|
|
3
19
|
appendAndCommit,
|
|
4
20
|
countBranchFileLines,
|
|
5
21
|
createOrphanBranch,
|
|
6
22
|
ensureRepoCloned,
|
|
7
|
-
ensureThinkDirs,
|
|
8
23
|
fetchBranch,
|
|
9
24
|
getConfig,
|
|
10
25
|
getConfigDir,
|
|
11
|
-
getCuratorMdPath,
|
|
12
|
-
getEngramDbPath,
|
|
13
|
-
getEngramsDir,
|
|
14
|
-
getLongtermPath,
|
|
15
|
-
getThinkDataDir,
|
|
16
26
|
listBranchFiles,
|
|
17
27
|
listRemoteBranches,
|
|
18
28
|
migrateToBuckets,
|
|
19
29
|
readFileFromBranch,
|
|
20
30
|
saveConfig
|
|
21
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-ZKUJ5M2W.js";
|
|
32
|
+
import {
|
|
33
|
+
ensureThinkDirs,
|
|
34
|
+
getCuratorMdPath,
|
|
35
|
+
getEngramsDir,
|
|
36
|
+
getLongtermPath,
|
|
37
|
+
getThinkDataDir
|
|
38
|
+
} from "./chunk-DCTG6IK4.js";
|
|
22
39
|
|
|
23
40
|
// src/index.ts
|
|
24
41
|
import fs11 from "fs";
|
|
@@ -153,159 +170,6 @@ function deleteEntriesByContent(pattern) {
|
|
|
153
170
|
|
|
154
171
|
// src/db/engram-queries.ts
|
|
155
172
|
import { v7 as uuidv72 } from "uuid";
|
|
156
|
-
|
|
157
|
-
// src/db/engrams.ts
|
|
158
|
-
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
159
|
-
|
|
160
|
-
// src/db/migrate.ts
|
|
161
|
-
function runMigrations(db2, migrations2) {
|
|
162
|
-
db2.exec(`
|
|
163
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
164
|
-
version INTEGER PRIMARY KEY NOT NULL,
|
|
165
|
-
applied_at TEXT NOT NULL
|
|
166
|
-
) STRICT;
|
|
167
|
-
`);
|
|
168
|
-
const currentVersion = db2.prepare(
|
|
169
|
-
"SELECT COALESCE(MAX(version), 0) as version FROM _migrations"
|
|
170
|
-
).get();
|
|
171
|
-
const pending = migrations2.filter((m) => m.version > currentVersion.version).sort((a, b) => a.version - b.version);
|
|
172
|
-
for (const migration of pending) {
|
|
173
|
-
db2.exec("BEGIN");
|
|
174
|
-
try {
|
|
175
|
-
migration.up(db2);
|
|
176
|
-
db2.prepare("INSERT INTO _migrations (version, applied_at) VALUES (?, ?)").run(
|
|
177
|
-
migration.version,
|
|
178
|
-
(/* @__PURE__ */ new Date()).toISOString()
|
|
179
|
-
);
|
|
180
|
-
db2.exec("COMMIT");
|
|
181
|
-
} catch (err) {
|
|
182
|
-
db2.exec("ROLLBACK");
|
|
183
|
-
throw new Error(`Migration v${migration.version} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// src/db/engrams.ts
|
|
189
|
-
var dbs = /* @__PURE__ */ new Map();
|
|
190
|
-
var migrations = [
|
|
191
|
-
{
|
|
192
|
-
version: 1,
|
|
193
|
-
up: (db2) => {
|
|
194
|
-
db2.exec(`
|
|
195
|
-
CREATE TABLE IF NOT EXISTS engrams (
|
|
196
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
197
|
-
content TEXT NOT NULL,
|
|
198
|
-
created_at TEXT NOT NULL,
|
|
199
|
-
expires_at TEXT NOT NULL,
|
|
200
|
-
evaluated_at TEXT,
|
|
201
|
-
promoted INTEGER,
|
|
202
|
-
deleted_at TEXT
|
|
203
|
-
) STRICT;
|
|
204
|
-
`);
|
|
205
|
-
db2.exec(`
|
|
206
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts
|
|
207
|
-
USING fts5(content, content='engrams', content_rowid='rowid');
|
|
208
|
-
`);
|
|
209
|
-
db2.exec(`
|
|
210
|
-
CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
|
|
211
|
-
INSERT INTO engrams_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
212
|
-
END;
|
|
213
|
-
`);
|
|
214
|
-
db2.exec(`
|
|
215
|
-
CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
|
|
216
|
-
INSERT INTO engrams_fts(engrams_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
217
|
-
END;
|
|
218
|
-
`);
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
version: 2,
|
|
223
|
-
up: (db2) => {
|
|
224
|
-
db2.exec(`
|
|
225
|
-
CREATE TABLE IF NOT EXISTS memories (
|
|
226
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
227
|
-
ts TEXT NOT NULL,
|
|
228
|
-
author TEXT NOT NULL,
|
|
229
|
-
content TEXT NOT NULL,
|
|
230
|
-
source_ids TEXT NOT NULL DEFAULT '[]',
|
|
231
|
-
created_at TEXT NOT NULL,
|
|
232
|
-
deleted_at TEXT,
|
|
233
|
-
sync_version INTEGER NOT NULL DEFAULT 0
|
|
234
|
-
) STRICT;
|
|
235
|
-
`);
|
|
236
|
-
db2.exec("CREATE INDEX IF NOT EXISTS idx_memories_ts ON memories(ts);");
|
|
237
|
-
db2.exec("CREATE INDEX IF NOT EXISTS idx_memories_sync_version ON memories(sync_version);");
|
|
238
|
-
db2.exec(`
|
|
239
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
240
|
-
USING fts5(content, content='memories', content_rowid='rowid');
|
|
241
|
-
`);
|
|
242
|
-
db2.exec(`
|
|
243
|
-
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
244
|
-
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
245
|
-
END;
|
|
246
|
-
`);
|
|
247
|
-
db2.exec(`
|
|
248
|
-
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
249
|
-
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
|
|
250
|
-
END;
|
|
251
|
-
`);
|
|
252
|
-
db2.exec(`
|
|
253
|
-
CREATE TABLE IF NOT EXISTS longterm_summary (
|
|
254
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
255
|
-
content TEXT NOT NULL,
|
|
256
|
-
updated_at TEXT NOT NULL,
|
|
257
|
-
sync_version INTEGER NOT NULL DEFAULT 0
|
|
258
|
-
) STRICT;
|
|
259
|
-
`);
|
|
260
|
-
db2.exec(`
|
|
261
|
-
CREATE TABLE IF NOT EXISTS sync_cursors (
|
|
262
|
-
backend TEXT NOT NULL,
|
|
263
|
-
direction TEXT NOT NULL,
|
|
264
|
-
cursor_value TEXT NOT NULL,
|
|
265
|
-
updated_at TEXT NOT NULL,
|
|
266
|
-
PRIMARY KEY (backend, direction)
|
|
267
|
-
) STRICT;
|
|
268
|
-
`);
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
version: 3,
|
|
273
|
-
up: (db2) => {
|
|
274
|
-
db2.exec("ALTER TABLE engrams ADD COLUMN episode_key TEXT;");
|
|
275
|
-
db2.exec("CREATE INDEX IF NOT EXISTS idx_engrams_episode_key ON engrams(episode_key);");
|
|
276
|
-
db2.exec("ALTER TABLE memories ADD COLUMN episode_key TEXT;");
|
|
277
|
-
db2.exec("CREATE INDEX IF NOT EXISTS idx_memories_episode_key ON memories(episode_key);");
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
version: 4,
|
|
282
|
-
up: (db2) => {
|
|
283
|
-
db2.exec("ALTER TABLE engrams ADD COLUMN context TEXT;");
|
|
284
|
-
db2.exec("ALTER TABLE engrams ADD COLUMN decisions TEXT;");
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
];
|
|
288
|
-
function getCortexDb(cortexName) {
|
|
289
|
-
const cached = dbs.get(cortexName);
|
|
290
|
-
if (cached) return cached;
|
|
291
|
-
ensureThinkDirs();
|
|
292
|
-
const dbPath = getEngramDbPath(cortexName);
|
|
293
|
-
const db2 = new DatabaseSync2(dbPath);
|
|
294
|
-
db2.exec("PRAGMA journal_mode = WAL");
|
|
295
|
-
db2.exec("PRAGMA synchronous = NORMAL");
|
|
296
|
-
runMigrations(db2, migrations);
|
|
297
|
-
dbs.set(cortexName, db2);
|
|
298
|
-
return db2;
|
|
299
|
-
}
|
|
300
|
-
function closeCortexDb(cortexName) {
|
|
301
|
-
const db2 = dbs.get(cortexName);
|
|
302
|
-
if (db2) {
|
|
303
|
-
db2.close();
|
|
304
|
-
dbs.delete(cortexName);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// src/db/engram-queries.ts
|
|
309
173
|
function insertEngram(cortexName, params) {
|
|
310
174
|
const db2 = getCortexDb(cortexName);
|
|
311
175
|
const id = uuidv72();
|
|
@@ -321,11 +185,11 @@ function insertEngram(cortexName, params) {
|
|
|
321
185
|
).run(id, params.content, created_at, expires_at, episodeKey, context, decisions);
|
|
322
186
|
return { id, content: params.content, created_at, expires_at, evaluated_at: null, promoted: null, deleted_at: null, episode_key: episodeKey, context, decisions };
|
|
323
187
|
}
|
|
324
|
-
function getPendingEngrams(cortexName) {
|
|
188
|
+
function getPendingEngrams(cortexName, limit = 200) {
|
|
325
189
|
const db2 = getCortexDb(cortexName);
|
|
326
190
|
return db2.prepare(
|
|
327
|
-
`SELECT * FROM engrams WHERE evaluated_at IS NULL AND deleted_at IS NULL AND episode_key IS NULL AND expires_at > ? ORDER BY created_at ASC
|
|
328
|
-
).all((/* @__PURE__ */ new Date()).toISOString());
|
|
191
|
+
`SELECT * FROM engrams WHERE evaluated_at IS NULL AND deleted_at IS NULL AND episode_key IS NULL AND expires_at > ? ORDER BY created_at ASC LIMIT ?`
|
|
192
|
+
).all((/* @__PURE__ */ new Date()).toISOString(), limit);
|
|
329
193
|
}
|
|
330
194
|
function getPendingEpisodeEngrams(cortexName, episodeKey) {
|
|
331
195
|
const db2 = getCortexDb(cortexName);
|
|
@@ -477,7 +341,7 @@ function validateEngramContent(content) {
|
|
|
477
341
|
return { content, warnings };
|
|
478
342
|
}
|
|
479
343
|
function wrapData(label, content) {
|
|
480
|
-
const escaped = content.replace(
|
|
344
|
+
const escaped = content.replace(/<\/?data/gi, (match) => `<${match.slice(1)}`);
|
|
481
345
|
return `<data source="${label}">
|
|
482
346
|
${escaped}
|
|
483
347
|
</data>`;
|
|
@@ -922,12 +786,20 @@ function readAuditLog() {
|
|
|
922
786
|
}
|
|
923
787
|
|
|
924
788
|
// src/commands/import.ts
|
|
789
|
+
var MAX_IMPORT_FILE_SIZE = 50 * 1024 * 1024;
|
|
790
|
+
var MAX_IMPORT_ENTRIES = 5e4;
|
|
925
791
|
var importCommand = new Command6("import").description("Import a sync bundle from another device").argument("<file>", "Path to the sync bundle JSON file").action((file) => {
|
|
926
792
|
if (!fs5.existsSync(file)) {
|
|
927
793
|
console.error(chalk6.red(`File not found: ${file}`));
|
|
928
794
|
closeDb();
|
|
929
795
|
process.exit(1);
|
|
930
796
|
}
|
|
797
|
+
const stat = fs5.statSync(file);
|
|
798
|
+
if (stat.size > MAX_IMPORT_FILE_SIZE) {
|
|
799
|
+
console.error(chalk6.red(`File too large (${Math.round(stat.size / 1024 / 1024)}MB). Maximum import size is ${MAX_IMPORT_FILE_SIZE / 1024 / 1024}MB.`));
|
|
800
|
+
closeDb();
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
931
803
|
let bundle;
|
|
932
804
|
try {
|
|
933
805
|
const raw = fs5.readFileSync(file, "utf-8");
|
|
@@ -937,7 +809,7 @@ var importCommand = new Command6("import").description("Import a sync bundle fro
|
|
|
937
809
|
closeDb();
|
|
938
810
|
process.exit(1);
|
|
939
811
|
}
|
|
940
|
-
if (bundle.format !== "think-sync-bundle" || !bundle.entries) {
|
|
812
|
+
if (bundle.format !== "think-sync-bundle" || !Array.isArray(bundle.entries)) {
|
|
941
813
|
console.error(chalk6.red("Not a valid think sync bundle."));
|
|
942
814
|
closeDb();
|
|
943
815
|
process.exit(1);
|
|
@@ -947,6 +819,11 @@ var importCommand = new Command6("import").description("Import a sync bundle fro
|
|
|
947
819
|
closeDb();
|
|
948
820
|
return;
|
|
949
821
|
}
|
|
822
|
+
if (bundle.entries.length > MAX_IMPORT_ENTRIES) {
|
|
823
|
+
console.error(chalk6.red(`Bundle contains ${bundle.entries.length} entries. Maximum is ${MAX_IMPORT_ENTRIES}.`));
|
|
824
|
+
closeDb();
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
950
827
|
const db2 = getDb();
|
|
951
828
|
const insert = db2.prepare(
|
|
952
829
|
`INSERT OR IGNORE INTO entries (id, timestamp, source, category, content, tags, deleted_at)
|
|
@@ -954,16 +831,23 @@ var importCommand = new Command6("import").description("Import a sync bundle fro
|
|
|
954
831
|
);
|
|
955
832
|
let imported = 0;
|
|
956
833
|
let skipped = 0;
|
|
834
|
+
let warnings = 0;
|
|
957
835
|
try {
|
|
958
836
|
db2.exec("BEGIN");
|
|
959
837
|
for (const entry of bundle.entries) {
|
|
838
|
+
if (typeof entry.id !== "string" || typeof entry.content !== "string") {
|
|
839
|
+
skipped++;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const validated = validateEngramContent(entry.content);
|
|
843
|
+
if (validated.warnings.length > 0) warnings++;
|
|
960
844
|
const result = insert.run(
|
|
961
845
|
entry.id,
|
|
962
|
-
entry.timestamp,
|
|
963
|
-
entry.source,
|
|
964
|
-
entry.category,
|
|
965
|
-
|
|
966
|
-
entry.tags,
|
|
846
|
+
typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
|
|
847
|
+
typeof entry.source === "string" ? entry.source : "import",
|
|
848
|
+
typeof entry.category === "string" ? entry.category : "",
|
|
849
|
+
validated.content,
|
|
850
|
+
typeof entry.tags === "string" ? entry.tags : "",
|
|
967
851
|
entry.deleted_at ?? null
|
|
968
852
|
);
|
|
969
853
|
if (result.changes > 0) {
|
|
@@ -989,10 +873,13 @@ var importCommand = new Command6("import").description("Import a sync bundle fro
|
|
|
989
873
|
count: bundle.entries.length
|
|
990
874
|
});
|
|
991
875
|
if (imported > 0) {
|
|
992
|
-
console.log(chalk6.green("\u2713") + ` Imported ${imported} entries` + (skipped > 0 ? ` (${skipped}
|
|
876
|
+
console.log(chalk6.green("\u2713") + ` Imported ${imported} entries` + (skipped > 0 ? ` (${skipped} skipped)` : ""));
|
|
993
877
|
} else {
|
|
994
878
|
console.log(chalk6.green("\u2713") + ` All ${skipped} entries already present \u2014 nothing new.`);
|
|
995
879
|
}
|
|
880
|
+
if (warnings > 0) {
|
|
881
|
+
console.log(chalk6.yellow("\u26A0") + ` ${warnings} entries contained suspicious content patterns`);
|
|
882
|
+
}
|
|
996
883
|
if (bundle.peerId) {
|
|
997
884
|
console.log(chalk6.dim(` from peer: ${bundle.peerId.slice(0, 8)}`));
|
|
998
885
|
}
|
|
@@ -1118,105 +1005,6 @@ import { Command as Command9 } from "commander";
|
|
|
1118
1005
|
import chalk9 from "chalk";
|
|
1119
1006
|
import readline2 from "readline";
|
|
1120
1007
|
|
|
1121
|
-
// src/db/memory-queries.ts
|
|
1122
|
-
import { v7 as uuidv73 } from "uuid";
|
|
1123
|
-
function insertMemory(cortexName, params) {
|
|
1124
|
-
const db2 = getCortexDb(cortexName);
|
|
1125
|
-
const id = params.id ?? uuidv73();
|
|
1126
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1127
|
-
const sourceIds = JSON.stringify(params.source_ids ?? []);
|
|
1128
|
-
const episodeKey = params.episode_key ?? null;
|
|
1129
|
-
db2.prepare(
|
|
1130
|
-
`INSERT INTO memories (id, ts, author, content, source_ids, created_at, deleted_at, sync_version, episode_key)
|
|
1131
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories), ?)`
|
|
1132
|
-
).run(id, params.ts, params.author, params.content, sourceIds, now, params.deleted_at ?? null, episodeKey);
|
|
1133
|
-
const row = db2.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
1134
|
-
return row;
|
|
1135
|
-
}
|
|
1136
|
-
function insertMemoryIfNotExists(cortexName, params) {
|
|
1137
|
-
const db2 = getCortexDb(cortexName);
|
|
1138
|
-
const existing = db2.prepare("SELECT id FROM memories WHERE id = ?").get(params.id);
|
|
1139
|
-
if (existing) return false;
|
|
1140
|
-
insertMemory(cortexName, params);
|
|
1141
|
-
return true;
|
|
1142
|
-
}
|
|
1143
|
-
function getMemories(cortexName, params = {}) {
|
|
1144
|
-
const db2 = getCortexDb(cortexName);
|
|
1145
|
-
const conditions = ["deleted_at IS NULL"];
|
|
1146
|
-
const values = [];
|
|
1147
|
-
if (params.since) {
|
|
1148
|
-
conditions.push("ts >= ?");
|
|
1149
|
-
values.push(params.since);
|
|
1150
|
-
}
|
|
1151
|
-
if (params.until) {
|
|
1152
|
-
conditions.push("ts <= ?");
|
|
1153
|
-
values.push(params.until);
|
|
1154
|
-
}
|
|
1155
|
-
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
1156
|
-
if (params.limit) {
|
|
1157
|
-
values.push(params.limit);
|
|
1158
|
-
return db2.prepare(
|
|
1159
|
-
`SELECT * FROM memories ${where} ORDER BY ts ASC LIMIT ?`
|
|
1160
|
-
).all(...values);
|
|
1161
|
-
}
|
|
1162
|
-
return db2.prepare(
|
|
1163
|
-
`SELECT * FROM memories ${where} ORDER BY ts ASC`
|
|
1164
|
-
).all(...values);
|
|
1165
|
-
}
|
|
1166
|
-
function getMemoriesBySyncVersion(cortexName, sinceVersion) {
|
|
1167
|
-
const db2 = getCortexDb(cortexName);
|
|
1168
|
-
return db2.prepare(
|
|
1169
|
-
"SELECT * FROM memories WHERE sync_version > ? ORDER BY sync_version ASC"
|
|
1170
|
-
).all(sinceVersion);
|
|
1171
|
-
}
|
|
1172
|
-
function tombstoneMemory(cortexName, id) {
|
|
1173
|
-
const db2 = getCortexDb(cortexName);
|
|
1174
|
-
db2.prepare(
|
|
1175
|
-
`UPDATE memories SET deleted_at = ?, sync_version = (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories)
|
|
1176
|
-
WHERE id = ? AND deleted_at IS NULL`
|
|
1177
|
-
).run((/* @__PURE__ */ new Date()).toISOString(), id);
|
|
1178
|
-
}
|
|
1179
|
-
function getLongtermSummary(cortexName) {
|
|
1180
|
-
const db2 = getCortexDb(cortexName);
|
|
1181
|
-
const row = db2.prepare("SELECT content FROM longterm_summary WHERE id = 1").get();
|
|
1182
|
-
return row?.content ?? null;
|
|
1183
|
-
}
|
|
1184
|
-
function setLongtermSummary(cortexName, content) {
|
|
1185
|
-
const db2 = getCortexDb(cortexName);
|
|
1186
|
-
db2.prepare(
|
|
1187
|
-
`INSERT INTO longterm_summary (id, content, updated_at, sync_version)
|
|
1188
|
-
VALUES (1, ?, ?, (SELECT COALESCE(MAX(sync_version), 0) + 1 FROM memories))
|
|
1189
|
-
ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at, sync_version = excluded.sync_version`
|
|
1190
|
-
).run(content, (/* @__PURE__ */ new Date()).toISOString());
|
|
1191
|
-
}
|
|
1192
|
-
function getSyncCursor(cortexName, backend, direction) {
|
|
1193
|
-
const db2 = getCortexDb(cortexName);
|
|
1194
|
-
const row = db2.prepare(
|
|
1195
|
-
"SELECT cursor_value FROM sync_cursors WHERE backend = ? AND direction = ?"
|
|
1196
|
-
).get(backend, direction);
|
|
1197
|
-
return row?.cursor_value ?? null;
|
|
1198
|
-
}
|
|
1199
|
-
function setSyncCursor(cortexName, backend, direction, cursorValue) {
|
|
1200
|
-
const db2 = getCortexDb(cortexName);
|
|
1201
|
-
db2.prepare(
|
|
1202
|
-
`INSERT INTO sync_cursors (backend, direction, cursor_value, updated_at)
|
|
1203
|
-
VALUES (?, ?, ?, ?)
|
|
1204
|
-
ON CONFLICT(backend, direction) DO UPDATE SET cursor_value = excluded.cursor_value, updated_at = excluded.updated_at`
|
|
1205
|
-
).run(backend, direction, cursorValue, (/* @__PURE__ */ new Date()).toISOString());
|
|
1206
|
-
}
|
|
1207
|
-
function getMemoryCount(cortexName) {
|
|
1208
|
-
const db2 = getCortexDb(cortexName);
|
|
1209
|
-
const row = db2.prepare("SELECT COUNT(*) as count FROM memories WHERE deleted_at IS NULL").get();
|
|
1210
|
-
return row.count;
|
|
1211
|
-
}
|
|
1212
|
-
function getMemoryByEpisodeKey(cortexName, episodeKey) {
|
|
1213
|
-
const db2 = getCortexDb(cortexName);
|
|
1214
|
-
const row = db2.prepare(
|
|
1215
|
-
"SELECT * FROM memories WHERE episode_key = ? AND deleted_at IS NULL LIMIT 1"
|
|
1216
|
-
).get(episodeKey);
|
|
1217
|
-
return row ?? null;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
1008
|
// src/lib/curator.ts
|
|
1221
1009
|
import fs7 from "fs";
|
|
1222
1010
|
import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -1623,11 +1411,15 @@ var GitSyncAdapter = class {
|
|
|
1623
1411
|
tombstoneMemory(cortex, id);
|
|
1624
1412
|
continue;
|
|
1625
1413
|
}
|
|
1414
|
+
const { content: sanitizedContent, warnings } = validateEngramContent(m.content);
|
|
1415
|
+
if (warnings.length > 0) {
|
|
1416
|
+
result.errors.push(`Pulled memory from ${m.author} flagged: ${warnings.join(", ")}`);
|
|
1417
|
+
}
|
|
1626
1418
|
const wasInserted = insertMemoryIfNotExists(cortex, {
|
|
1627
1419
|
id,
|
|
1628
1420
|
ts: m.ts,
|
|
1629
1421
|
author: m.author,
|
|
1630
|
-
content:
|
|
1422
|
+
content: sanitizedContent,
|
|
1631
1423
|
source_ids: m.source_ids,
|
|
1632
1424
|
episode_key: m.episode_key
|
|
1633
1425
|
});
|
|
@@ -1752,7 +1544,7 @@ cortexCommand.addCommand(new Command9("setup").description("Configure a sync bac
|
|
|
1752
1544
|
const adapter = getSyncAdapter();
|
|
1753
1545
|
if (adapter) {
|
|
1754
1546
|
try {
|
|
1755
|
-
const { ensureRepoCloned: ensureRepoCloned2 } = await import("./git-
|
|
1547
|
+
const { ensureRepoCloned: ensureRepoCloned2 } = await import("./git-TG6OJFBT.js");
|
|
1756
1548
|
ensureRepoCloned2();
|
|
1757
1549
|
console.log(chalk9.green("\u2713") + " Repo cloned");
|
|
1758
1550
|
} catch (err) {
|
|
@@ -2256,44 +2048,77 @@ var monitorCommand = new Command11("monitor").description("Show what got promote
|
|
|
2256
2048
|
// src/commands/recall.ts
|
|
2257
2049
|
import { Command as Command12 } from "commander";
|
|
2258
2050
|
import chalk12 from "chalk";
|
|
2259
|
-
var recallCommand = new Command12("recall").argument("<query>", "What to recall").description("Search memories and local engrams").option("--days <n>", "Days of memories to include", "14").action(async (query3, opts) => {
|
|
2051
|
+
var recallCommand = new Command12("recall").argument("<query>", "What to recall").description("Search memories and local engrams").option("--engrams", "Also search local engrams (not just memories)").option("--all", "Dump all recent memories + long-term summary (ignores query for memories)").option("--days <n>", "Days of memories to include (only with --all)", "14").option("--limit <n>", "Max results to return", "20").action(async (query3, opts) => {
|
|
2260
2052
|
const config = getConfig();
|
|
2261
2053
|
const cortex = config.cortex?.active;
|
|
2262
2054
|
if (!cortex) {
|
|
2263
2055
|
console.error(chalk12.red("No active cortex. Run: think cortex switch <name>"));
|
|
2264
2056
|
process.exit(1);
|
|
2265
2057
|
}
|
|
2266
|
-
const
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2058
|
+
const limit = parseInt(opts.limit, 10);
|
|
2059
|
+
if (opts.all) {
|
|
2060
|
+
const { getMemories: getMemories2 } = await import("./memory-queries-IPGGUAQW.js");
|
|
2061
|
+
const days = parseInt(opts.days, 10);
|
|
2062
|
+
const cutoff = new Date(Date.now() - days * 864e5).toISOString();
|
|
2063
|
+
const recentMemories = getMemories2(cortex, { since: cutoff });
|
|
2064
|
+
const longterm = getLongtermSummary(cortex);
|
|
2065
|
+
const matchingEngrams = searchEngrams(cortex, query3);
|
|
2066
|
+
if (recentMemories.length > 0) {
|
|
2067
|
+
console.log(chalk12.cyan(`Team memories (last ${days} days):`));
|
|
2068
|
+
for (const m of recentMemories) {
|
|
2069
|
+
const ts = m.ts.slice(0, 16).replace("T", " ");
|
|
2070
|
+
console.log(` ${chalk12.gray(ts)} ${chalk12.dim(m.author + ":")} ${m.content}`);
|
|
2071
|
+
}
|
|
2072
|
+
console.log();
|
|
2073
|
+
}
|
|
2074
|
+
if (longterm) {
|
|
2075
|
+
console.log(chalk12.cyan("Long-term context:"));
|
|
2076
|
+
console.log(` ${longterm}`);
|
|
2077
|
+
console.log();
|
|
2078
|
+
}
|
|
2079
|
+
if (matchingEngrams.length > 0) {
|
|
2080
|
+
console.log(chalk12.cyan(`Matching engrams (local):`));
|
|
2081
|
+
for (const e of matchingEngrams) {
|
|
2082
|
+
const ts = e.created_at.slice(0, 16).replace("T", " ");
|
|
2083
|
+
console.log(` ${chalk12.gray(ts)} ${e.content}`);
|
|
2084
|
+
}
|
|
2085
|
+
console.log();
|
|
2086
|
+
}
|
|
2087
|
+
if (recentMemories.length === 0 && matchingEngrams.length === 0 && !longterm) {
|
|
2088
|
+
console.log(chalk12.dim("No results found."));
|
|
2089
|
+
}
|
|
2090
|
+
closeCortexDb(cortex);
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const matchingMemories = searchMemories(cortex, query3, limit);
|
|
2094
|
+
if (matchingMemories.length > 0) {
|
|
2095
|
+
console.log(chalk12.cyan(`Matching memories (${matchingMemories.length}):`));
|
|
2096
|
+
for (const m of matchingMemories) {
|
|
2274
2097
|
const ts = m.ts.slice(0, 16).replace("T", " ");
|
|
2275
2098
|
console.log(` ${chalk12.gray(ts)} ${chalk12.dim(m.author + ":")} ${m.content}`);
|
|
2276
2099
|
}
|
|
2277
2100
|
console.log();
|
|
2278
2101
|
} else {
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
if (matchingEngrams.length > 0) {
|
|
2288
|
-
console.log(chalk12.cyan(`Matching engrams (local):`));
|
|
2289
|
-
for (const e of matchingEngrams) {
|
|
2290
|
-
const ts = e.created_at.slice(0, 16).replace("T", " ");
|
|
2291
|
-
console.log(` ${chalk12.gray(ts)} ${e.content}`);
|
|
2102
|
+
const longterm = getLongtermSummary(cortex);
|
|
2103
|
+
if (longterm) {
|
|
2104
|
+
console.log(chalk12.dim("No matching memories. Showing long-term context:"));
|
|
2105
|
+
console.log(` ${longterm}`);
|
|
2106
|
+
console.log();
|
|
2107
|
+
} else {
|
|
2108
|
+
console.log(chalk12.dim("No matching memories."));
|
|
2109
|
+
console.log();
|
|
2292
2110
|
}
|
|
2293
|
-
console.log();
|
|
2294
2111
|
}
|
|
2295
|
-
if (
|
|
2296
|
-
|
|
2112
|
+
if (opts.engrams) {
|
|
2113
|
+
const matchingEngrams = searchEngrams(cortex, query3, limit);
|
|
2114
|
+
if (matchingEngrams.length > 0) {
|
|
2115
|
+
console.log(chalk12.cyan(`Matching engrams (${matchingEngrams.length}):`));
|
|
2116
|
+
for (const e of matchingEngrams) {
|
|
2117
|
+
const ts = e.created_at.slice(0, 16).replace("T", " ");
|
|
2118
|
+
console.log(` ${chalk12.gray(ts)} ${e.content}`);
|
|
2119
|
+
}
|
|
2120
|
+
console.log();
|
|
2121
|
+
}
|
|
2297
2122
|
}
|
|
2298
2123
|
closeCortexDb(cortex);
|
|
2299
2124
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
import {
|
|
3
|
+
getLongtermSummary,
|
|
4
|
+
getMemories,
|
|
5
|
+
getMemoriesBySyncVersion,
|
|
6
|
+
getMemoryByEpisodeKey,
|
|
7
|
+
getMemoryCount,
|
|
8
|
+
getSyncCursor,
|
|
9
|
+
insertMemory,
|
|
10
|
+
insertMemoryIfNotExists,
|
|
11
|
+
searchMemories,
|
|
12
|
+
setLongtermSummary,
|
|
13
|
+
setSyncCursor,
|
|
14
|
+
tombstoneMemory
|
|
15
|
+
} from "./chunk-MSOBQE64.js";
|
|
16
|
+
import "./chunk-DCTG6IK4.js";
|
|
17
|
+
export {
|
|
18
|
+
getLongtermSummary,
|
|
19
|
+
getMemories,
|
|
20
|
+
getMemoriesBySyncVersion,
|
|
21
|
+
getMemoryByEpisodeKey,
|
|
22
|
+
getMemoryCount,
|
|
23
|
+
getSyncCursor,
|
|
24
|
+
insertMemory,
|
|
25
|
+
insertMemoryIfNotExists,
|
|
26
|
+
searchMemories,
|
|
27
|
+
setLongtermSummary,
|
|
28
|
+
setSyncCursor,
|
|
29
|
+
tombstoneMemory
|
|
30
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-think",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local-first CLI that gives AI agents persistent, curated memory",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"homepage": "https://openthink.dev",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/
|
|
20
|
+
"url": "git+https://github.com/OpenThinkAi/think-cli.git"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"cli",
|