@kitakitsune/memoria 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.
- package/README.md +172 -0
- package/bin/memoria.js +6 -0
- package/dist/chunk-R6AR3ICV.js +931 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.js +377 -0
- package/dist/index.d.ts +136 -0
- package/dist/index.js +58 -0
- package/package.json +59 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var MEMORY_TYPES = [
|
|
3
|
+
"fact",
|
|
4
|
+
"decision",
|
|
5
|
+
"lesson",
|
|
6
|
+
"commitment",
|
|
7
|
+
"preference",
|
|
8
|
+
"relationship",
|
|
9
|
+
"project"
|
|
10
|
+
];
|
|
11
|
+
var TYPE_TO_CATEGORY = {
|
|
12
|
+
fact: "facts",
|
|
13
|
+
decision: "decisions",
|
|
14
|
+
lesson: "lessons",
|
|
15
|
+
commitment: "commitments",
|
|
16
|
+
preference: "preferences",
|
|
17
|
+
relationship: "people",
|
|
18
|
+
project: "projects"
|
|
19
|
+
};
|
|
20
|
+
var DEFAULT_CATEGORIES = [
|
|
21
|
+
"decisions",
|
|
22
|
+
"preferences",
|
|
23
|
+
"lessons",
|
|
24
|
+
"facts",
|
|
25
|
+
"commitments",
|
|
26
|
+
"people",
|
|
27
|
+
"projects",
|
|
28
|
+
"inbox",
|
|
29
|
+
"sessions",
|
|
30
|
+
"observations"
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// src/lib/config.ts
|
|
34
|
+
import { readFile, writeFile } from "fs/promises";
|
|
35
|
+
import { join } from "path";
|
|
36
|
+
var CONFIG_FILE = ".memoria.json";
|
|
37
|
+
function configPath(vaultPath) {
|
|
38
|
+
return join(vaultPath, CONFIG_FILE);
|
|
39
|
+
}
|
|
40
|
+
async function readConfig(vaultPath) {
|
|
41
|
+
const raw = await readFile(configPath(vaultPath), "utf-8");
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
async function writeConfig(config) {
|
|
45
|
+
const filePath = configPath(config.path);
|
|
46
|
+
await writeFile(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
47
|
+
}
|
|
48
|
+
async function configExists(vaultPath) {
|
|
49
|
+
try {
|
|
50
|
+
await readFile(configPath(vaultPath));
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/lib/document.ts
|
|
58
|
+
import matter from "gray-matter";
|
|
59
|
+
function parseDocument(raw, filePath, category) {
|
|
60
|
+
const { data, content } = matter(raw);
|
|
61
|
+
const id = filePath.replace(/\.md$/, "");
|
|
62
|
+
const tags = Array.isArray(data.tags) ? data.tags : [];
|
|
63
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
path: filePath,
|
|
67
|
+
category,
|
|
68
|
+
title: data.title || id.split("/").pop() || id,
|
|
69
|
+
content: content.trim(),
|
|
70
|
+
frontmatter: data,
|
|
71
|
+
tags,
|
|
72
|
+
created: data.created || now,
|
|
73
|
+
updated: data.updated || now
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function serializeDocument(doc) {
|
|
77
|
+
const fm = {
|
|
78
|
+
title: doc.title,
|
|
79
|
+
...doc.frontmatter
|
|
80
|
+
};
|
|
81
|
+
if (doc.type) fm.type = doc.type;
|
|
82
|
+
if (doc.tags && doc.tags.length > 0) fm.tags = doc.tags;
|
|
83
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
84
|
+
if (!fm.created) fm.created = now;
|
|
85
|
+
fm.updated = now;
|
|
86
|
+
return matter.stringify(doc.content || "", fm);
|
|
87
|
+
}
|
|
88
|
+
function slugify(text) {
|
|
89
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/lib/vault.ts
|
|
93
|
+
import { mkdir, readFile as readFile2, writeFile as writeFile2, readdir, stat, unlink } from "fs/promises";
|
|
94
|
+
import { join as join2, relative, extname, dirname } from "path";
|
|
95
|
+
async function initVault(vaultPath, name, categories) {
|
|
96
|
+
const cats = categories || DEFAULT_CATEGORIES;
|
|
97
|
+
await mkdir(vaultPath, { recursive: true });
|
|
98
|
+
for (const cat of cats) {
|
|
99
|
+
await mkdir(join2(vaultPath, cat), { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
await mkdir(join2(vaultPath, "sessions", "handoffs"), { recursive: true });
|
|
102
|
+
const config = {
|
|
103
|
+
path: vaultPath,
|
|
104
|
+
name,
|
|
105
|
+
categories: cats
|
|
106
|
+
};
|
|
107
|
+
await writeConfig(config);
|
|
108
|
+
return config;
|
|
109
|
+
}
|
|
110
|
+
async function resolveVault(startPath) {
|
|
111
|
+
const searchPath = startPath || process.cwd();
|
|
112
|
+
if (await configExists(searchPath)) {
|
|
113
|
+
return readConfig(searchPath);
|
|
114
|
+
}
|
|
115
|
+
const envPath = process.env.MEMORIA_VAULT;
|
|
116
|
+
if (envPath && await configExists(envPath)) {
|
|
117
|
+
return readConfig(envPath);
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
`No Memoria vault found at "${searchPath}". Run "memoria init" first or set MEMORIA_VAULT.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
async function storeDocument(vaultPath, options) {
|
|
124
|
+
const slug = slugify(options.title);
|
|
125
|
+
const filePath = join2(vaultPath, options.category, `${slug}.md`);
|
|
126
|
+
const relPath = relative(vaultPath, filePath);
|
|
127
|
+
if (!options.overwrite) {
|
|
128
|
+
let exists = false;
|
|
129
|
+
try {
|
|
130
|
+
await stat(filePath);
|
|
131
|
+
exists = true;
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
if (exists) {
|
|
135
|
+
throw new Error(`Document already exists: ${relPath}. Use --overwrite to replace.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const raw = serializeDocument({
|
|
139
|
+
title: options.title,
|
|
140
|
+
content: options.content,
|
|
141
|
+
frontmatter: options.frontmatter,
|
|
142
|
+
tags: options.frontmatter?.tags
|
|
143
|
+
});
|
|
144
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
145
|
+
await writeFile2(filePath, raw, "utf-8");
|
|
146
|
+
return parseDocument(raw, relPath, options.category);
|
|
147
|
+
}
|
|
148
|
+
async function getDocument(vaultPath, id) {
|
|
149
|
+
let filePath = join2(vaultPath, id);
|
|
150
|
+
if (extname(filePath) !== ".md") filePath += ".md";
|
|
151
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
152
|
+
const parts = relative(vaultPath, filePath).split("/");
|
|
153
|
+
const category = parts.length > 1 ? parts[0] : "inbox";
|
|
154
|
+
return parseDocument(raw, relative(vaultPath, filePath), category);
|
|
155
|
+
}
|
|
156
|
+
async function listDocuments(vaultPath, category) {
|
|
157
|
+
const config = await readConfig(vaultPath);
|
|
158
|
+
const categories = category ? [category] : config.categories;
|
|
159
|
+
const docs = [];
|
|
160
|
+
for (const cat of categories) {
|
|
161
|
+
const catDir = join2(vaultPath, cat);
|
|
162
|
+
let entries;
|
|
163
|
+
try {
|
|
164
|
+
entries = await readdir(catDir);
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
if (!entry.endsWith(".md")) continue;
|
|
170
|
+
const filePath = join2(catDir, entry);
|
|
171
|
+
try {
|
|
172
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
173
|
+
docs.push(parseDocument(raw, relative(vaultPath, filePath), cat));
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return docs;
|
|
180
|
+
}
|
|
181
|
+
async function deleteDocument(vaultPath, id) {
|
|
182
|
+
let filePath = join2(vaultPath, id);
|
|
183
|
+
if (extname(filePath) !== ".md") filePath += ".md";
|
|
184
|
+
await unlink(filePath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/lib/search.ts
|
|
188
|
+
import natural from "natural";
|
|
189
|
+
var { TfIdf } = natural;
|
|
190
|
+
function searchDocuments(docs, query, options = {}) {
|
|
191
|
+
const { limit = 10, minScore = 0.01, category, tags } = options;
|
|
192
|
+
let filtered = docs;
|
|
193
|
+
if (category) {
|
|
194
|
+
filtered = filtered.filter((d) => d.category === category);
|
|
195
|
+
}
|
|
196
|
+
if (tags && tags.length > 0) {
|
|
197
|
+
const lowerTags = tags.map((t) => t.toLowerCase());
|
|
198
|
+
filtered = filtered.filter(
|
|
199
|
+
(d) => lowerTags.some((lt) => d.tags.map((t) => t.toLowerCase()).includes(lt))
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (filtered.length === 0) return [];
|
|
203
|
+
const tfidf = new TfIdf();
|
|
204
|
+
for (const doc of filtered) {
|
|
205
|
+
const text = `${doc.title} ${doc.tags.join(" ")} ${doc.content}`;
|
|
206
|
+
tfidf.addDocument(text);
|
|
207
|
+
}
|
|
208
|
+
const scored = [];
|
|
209
|
+
tfidf.tfidfs(query, (i, measure) => {
|
|
210
|
+
if (measure > minScore) {
|
|
211
|
+
scored.push({ index: i, score: measure });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
scored.sort((a, b) => b.score - a.score);
|
|
215
|
+
const maxScore = scored.length > 0 ? scored[0].score : 1;
|
|
216
|
+
return scored.slice(0, limit).map(({ index, score }) => {
|
|
217
|
+
const doc = filtered[index];
|
|
218
|
+
const normalizedScore = maxScore > 0 ? score / maxScore : 0;
|
|
219
|
+
return {
|
|
220
|
+
document: doc,
|
|
221
|
+
score: normalizedScore,
|
|
222
|
+
snippet: extractSnippet(doc.content, query)
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function extractSnippet(content, query, maxLen = 150) {
|
|
227
|
+
const lower = content.toLowerCase();
|
|
228
|
+
const terms = query.toLowerCase().split(/\s+/);
|
|
229
|
+
for (const term of terms) {
|
|
230
|
+
const idx = lower.indexOf(term);
|
|
231
|
+
if (idx !== -1) {
|
|
232
|
+
const start = Math.max(0, idx - 40);
|
|
233
|
+
const end = Math.min(content.length, idx + maxLen - 40);
|
|
234
|
+
let snippet = content.slice(start, end).trim();
|
|
235
|
+
if (start > 0) snippet = "..." + snippet;
|
|
236
|
+
if (end < content.length) snippet = snippet + "...";
|
|
237
|
+
return snippet;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return content.slice(0, maxLen).trim() + (content.length > maxLen ? "..." : "");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/lib/session.ts
|
|
244
|
+
import { readFile as readFile3, writeFile as writeFile3, readdir as readdir2, mkdir as mkdir2 } from "fs/promises";
|
|
245
|
+
import { join as join3 } from "path";
|
|
246
|
+
import matter2 from "gray-matter";
|
|
247
|
+
var SESSION_FILE = "sessions/current.md";
|
|
248
|
+
var HANDOFFS_DIR = "sessions/handoffs";
|
|
249
|
+
function sessionPath(vaultPath) {
|
|
250
|
+
return join3(vaultPath, SESSION_FILE);
|
|
251
|
+
}
|
|
252
|
+
function handoffsDir(vaultPath) {
|
|
253
|
+
return join3(vaultPath, HANDOFFS_DIR);
|
|
254
|
+
}
|
|
255
|
+
async function getSession(vaultPath) {
|
|
256
|
+
try {
|
|
257
|
+
const raw = await readFile3(sessionPath(vaultPath), "utf-8");
|
|
258
|
+
const { data } = matter2(raw);
|
|
259
|
+
return {
|
|
260
|
+
state: data.state || "idle",
|
|
261
|
+
startedAt: data.startedAt,
|
|
262
|
+
workingOn: data.workingOn,
|
|
263
|
+
focus: data.focus,
|
|
264
|
+
lastCheckpoint: data.lastCheckpoint
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
return { state: "idle" };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function saveSession(vaultPath, session) {
|
|
271
|
+
const content = session.workingOn ? `Currently working on: ${session.workingOn}` : "";
|
|
272
|
+
const fm = {};
|
|
273
|
+
for (const [key, val] of Object.entries(session)) {
|
|
274
|
+
if (val !== void 0) fm[key] = val;
|
|
275
|
+
}
|
|
276
|
+
const raw = matter2.stringify(content, fm);
|
|
277
|
+
await writeFile3(sessionPath(vaultPath), raw, "utf-8");
|
|
278
|
+
}
|
|
279
|
+
async function startSession(vaultPath) {
|
|
280
|
+
const session = {
|
|
281
|
+
state: "active",
|
|
282
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
283
|
+
};
|
|
284
|
+
await saveSession(vaultPath, session);
|
|
285
|
+
return session;
|
|
286
|
+
}
|
|
287
|
+
async function updateCheckpoint(vaultPath, workingOn, focus) {
|
|
288
|
+
const session = await getSession(vaultPath);
|
|
289
|
+
session.state = "active";
|
|
290
|
+
session.lastCheckpoint = (/* @__PURE__ */ new Date()).toISOString();
|
|
291
|
+
if (workingOn) session.workingOn = workingOn;
|
|
292
|
+
if (focus) session.focus = focus;
|
|
293
|
+
await saveSession(vaultPath, session);
|
|
294
|
+
return session;
|
|
295
|
+
}
|
|
296
|
+
async function endSession(vaultPath, summary, nextSteps) {
|
|
297
|
+
const session = await getSession(vaultPath);
|
|
298
|
+
const now = /* @__PURE__ */ new Date();
|
|
299
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
300
|
+
const handoff = {
|
|
301
|
+
created: now.toISOString(),
|
|
302
|
+
summary,
|
|
303
|
+
workingOn: session.workingOn,
|
|
304
|
+
focus: session.focus,
|
|
305
|
+
nextSteps
|
|
306
|
+
};
|
|
307
|
+
const dir = handoffsDir(vaultPath);
|
|
308
|
+
await mkdir2(dir, { recursive: true });
|
|
309
|
+
const handoffContent = matter2.stringify(
|
|
310
|
+
[
|
|
311
|
+
`## Summary
|
|
312
|
+
${summary}`,
|
|
313
|
+
session.workingOn ? `## Working On
|
|
314
|
+
${session.workingOn}` : "",
|
|
315
|
+
session.focus ? `## Focus
|
|
316
|
+
${session.focus}` : "",
|
|
317
|
+
nextSteps ? `## Next Steps
|
|
318
|
+
${nextSteps}` : ""
|
|
319
|
+
].filter(Boolean).join("\n\n"),
|
|
320
|
+
{
|
|
321
|
+
created: handoff.created,
|
|
322
|
+
type: "handoff"
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
const handoffPath = join3(dir, `${dateStr}.md`);
|
|
326
|
+
await writeFile3(handoffPath, handoffContent, "utf-8");
|
|
327
|
+
const idleSession = { state: "idle" };
|
|
328
|
+
await saveSession(vaultPath, idleSession);
|
|
329
|
+
return handoff;
|
|
330
|
+
}
|
|
331
|
+
async function getRecentHandoffs(vaultPath, limit = 3) {
|
|
332
|
+
const dir = handoffsDir(vaultPath);
|
|
333
|
+
let entries;
|
|
334
|
+
try {
|
|
335
|
+
entries = await readdir2(dir);
|
|
336
|
+
} catch {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const mdFiles = entries.filter((e) => e.endsWith(".md")).sort().reverse();
|
|
340
|
+
const handoffs = [];
|
|
341
|
+
for (const file of mdFiles.slice(0, limit)) {
|
|
342
|
+
try {
|
|
343
|
+
const raw = await readFile3(join3(dir, file), "utf-8");
|
|
344
|
+
const { data, content } = matter2(raw);
|
|
345
|
+
handoffs.push({
|
|
346
|
+
created: data.created || file.replace(".md", ""),
|
|
347
|
+
summary: content.trim(),
|
|
348
|
+
workingOn: data.workingOn,
|
|
349
|
+
focus: data.focus,
|
|
350
|
+
nextSteps: data.nextSteps
|
|
351
|
+
});
|
|
352
|
+
} catch {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return handoffs;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/lib/notion-client.ts
|
|
360
|
+
import { Client } from "@notionhq/client";
|
|
361
|
+
function createNotionClient(token) {
|
|
362
|
+
return new Client({ auth: token });
|
|
363
|
+
}
|
|
364
|
+
async function createCategoryDatabase(client, parentPageId, categoryName) {
|
|
365
|
+
return client.databases.create({
|
|
366
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
367
|
+
title: [{ type: "text", text: { content: categoryName } }],
|
|
368
|
+
properties: {
|
|
369
|
+
Title: { title: {} },
|
|
370
|
+
Type: { select: { options: [] } },
|
|
371
|
+
Tags: { multi_select: { options: [] } },
|
|
372
|
+
Created: { date: {} },
|
|
373
|
+
Updated: { date: {} }
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
async function createNotionPage(client, databaseId, title, properties, children) {
|
|
378
|
+
const props = {
|
|
379
|
+
Title: { title: [{ text: { content: title } }] }
|
|
380
|
+
};
|
|
381
|
+
if (properties.type) {
|
|
382
|
+
props.Type = { select: { name: properties.type } };
|
|
383
|
+
}
|
|
384
|
+
if (properties.tags && properties.tags.length > 0) {
|
|
385
|
+
props.Tags = {
|
|
386
|
+
multi_select: properties.tags.map((t) => ({ name: t }))
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (properties.created) {
|
|
390
|
+
props.Created = { date: { start: properties.created } };
|
|
391
|
+
}
|
|
392
|
+
if (properties.updated) {
|
|
393
|
+
props.Updated = { date: { start: properties.updated } };
|
|
394
|
+
}
|
|
395
|
+
const result = await client.pages.create({
|
|
396
|
+
parent: { database_id: databaseId },
|
|
397
|
+
properties: props,
|
|
398
|
+
children: children || []
|
|
399
|
+
});
|
|
400
|
+
return { id: result.id };
|
|
401
|
+
}
|
|
402
|
+
async function updateNotionPage(client, pageId, properties) {
|
|
403
|
+
const props = {};
|
|
404
|
+
if (properties.title) {
|
|
405
|
+
props.Title = { title: [{ text: { content: properties.title } }] };
|
|
406
|
+
}
|
|
407
|
+
if (properties.type) {
|
|
408
|
+
props.Type = { select: { name: properties.type } };
|
|
409
|
+
}
|
|
410
|
+
if (properties.tags) {
|
|
411
|
+
props.Tags = {
|
|
412
|
+
multi_select: properties.tags.map((t) => ({ name: t }))
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (properties.updated) {
|
|
416
|
+
props.Updated = { date: { start: properties.updated } };
|
|
417
|
+
}
|
|
418
|
+
await client.pages.update({
|
|
419
|
+
page_id: pageId,
|
|
420
|
+
properties: props
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
async function replacePageContent(client, pageId, children) {
|
|
424
|
+
const existing = await client.blocks.children.list({ block_id: pageId });
|
|
425
|
+
for (const block of existing.results) {
|
|
426
|
+
await client.blocks.delete({ block_id: block.id });
|
|
427
|
+
}
|
|
428
|
+
if (children.length > 0) {
|
|
429
|
+
await client.blocks.children.append({
|
|
430
|
+
block_id: pageId,
|
|
431
|
+
children
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async function queryDatabase(client, databaseId, lastSyncAt) {
|
|
436
|
+
const filter = lastSyncAt ? {
|
|
437
|
+
timestamp: "last_edited_time",
|
|
438
|
+
last_edited_time: { after: lastSyncAt }
|
|
439
|
+
} : void 0;
|
|
440
|
+
return client.databases.query({
|
|
441
|
+
database_id: databaseId,
|
|
442
|
+
filter
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function getPageBlocks(client, pageId) {
|
|
446
|
+
const blocks = [];
|
|
447
|
+
let cursor;
|
|
448
|
+
do {
|
|
449
|
+
const response = await client.blocks.children.list({
|
|
450
|
+
block_id: pageId,
|
|
451
|
+
start_cursor: cursor
|
|
452
|
+
});
|
|
453
|
+
blocks.push(...response.results);
|
|
454
|
+
cursor = response.has_more ? response.next_cursor ?? void 0 : void 0;
|
|
455
|
+
} while (cursor);
|
|
456
|
+
return blocks;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/lib/notion-converter.ts
|
|
460
|
+
function markdownToBlocks(markdown) {
|
|
461
|
+
const lines = markdown.split("\n");
|
|
462
|
+
const blocks = [];
|
|
463
|
+
let i = 0;
|
|
464
|
+
while (i < lines.length) {
|
|
465
|
+
const line = lines[i];
|
|
466
|
+
if (line.trim() === "") {
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const h1 = line.match(/^# (.+)$/);
|
|
471
|
+
if (h1) {
|
|
472
|
+
blocks.push({
|
|
473
|
+
object: "block",
|
|
474
|
+
type: "heading_1",
|
|
475
|
+
heading_1: { rich_text: parseInlineMarkdown(h1[1]) }
|
|
476
|
+
});
|
|
477
|
+
i++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const h2 = line.match(/^## (.+)$/);
|
|
481
|
+
if (h2) {
|
|
482
|
+
blocks.push({
|
|
483
|
+
object: "block",
|
|
484
|
+
type: "heading_2",
|
|
485
|
+
heading_2: { rich_text: parseInlineMarkdown(h2[1]) }
|
|
486
|
+
});
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const h3 = line.match(/^### (.+)$/);
|
|
491
|
+
if (h3) {
|
|
492
|
+
blocks.push({
|
|
493
|
+
object: "block",
|
|
494
|
+
type: "heading_3",
|
|
495
|
+
heading_3: { rich_text: parseInlineMarkdown(h3[1]) }
|
|
496
|
+
});
|
|
497
|
+
i++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (line.match(/^```/)) {
|
|
501
|
+
const langMatch = line.match(/^```(\w*)$/);
|
|
502
|
+
const language = langMatch?.[1] || "plain text";
|
|
503
|
+
const codeLines = [];
|
|
504
|
+
i++;
|
|
505
|
+
while (i < lines.length && !lines[i].match(/^```$/)) {
|
|
506
|
+
codeLines.push(lines[i]);
|
|
507
|
+
i++;
|
|
508
|
+
}
|
|
509
|
+
i++;
|
|
510
|
+
blocks.push({
|
|
511
|
+
object: "block",
|
|
512
|
+
type: "code",
|
|
513
|
+
code: {
|
|
514
|
+
rich_text: [{ type: "text", text: { content: codeLines.join("\n") } }],
|
|
515
|
+
language: mapLanguage(language)
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (line.match(/^> /)) {
|
|
521
|
+
const quoteLines = [];
|
|
522
|
+
while (i < lines.length && lines[i].match(/^> /)) {
|
|
523
|
+
quoteLines.push(lines[i].replace(/^> /, ""));
|
|
524
|
+
i++;
|
|
525
|
+
}
|
|
526
|
+
blocks.push({
|
|
527
|
+
object: "block",
|
|
528
|
+
type: "quote",
|
|
529
|
+
quote: { rich_text: parseInlineMarkdown(quoteLines.join("\n")) }
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (line.match(/^[-*] /)) {
|
|
534
|
+
blocks.push({
|
|
535
|
+
object: "block",
|
|
536
|
+
type: "bulleted_list_item",
|
|
537
|
+
bulleted_list_item: { rich_text: parseInlineMarkdown(line.replace(/^[-*] /, "")) }
|
|
538
|
+
});
|
|
539
|
+
i++;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const numMatch = line.match(/^\d+\. (.+)$/);
|
|
543
|
+
if (numMatch) {
|
|
544
|
+
blocks.push({
|
|
545
|
+
object: "block",
|
|
546
|
+
type: "numbered_list_item",
|
|
547
|
+
numbered_list_item: { rich_text: parseInlineMarkdown(numMatch[1]) }
|
|
548
|
+
});
|
|
549
|
+
i++;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (line.match(/^(-{3,}|_{3,}|\*{3,})$/)) {
|
|
553
|
+
blocks.push({
|
|
554
|
+
object: "block",
|
|
555
|
+
type: "divider",
|
|
556
|
+
divider: {}
|
|
557
|
+
});
|
|
558
|
+
i++;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
blocks.push({
|
|
562
|
+
object: "block",
|
|
563
|
+
type: "paragraph",
|
|
564
|
+
paragraph: { rich_text: parseInlineMarkdown(line) }
|
|
565
|
+
});
|
|
566
|
+
i++;
|
|
567
|
+
}
|
|
568
|
+
return blocks;
|
|
569
|
+
}
|
|
570
|
+
function blocksToMarkdown(blocks) {
|
|
571
|
+
const lines = [];
|
|
572
|
+
for (const block of blocks) {
|
|
573
|
+
const b = block;
|
|
574
|
+
const type = b.type;
|
|
575
|
+
switch (type) {
|
|
576
|
+
case "paragraph":
|
|
577
|
+
lines.push(richTextToMarkdown(getBlockRichText(b, "paragraph")));
|
|
578
|
+
lines.push("");
|
|
579
|
+
break;
|
|
580
|
+
case "heading_1":
|
|
581
|
+
lines.push(`# ${richTextToMarkdown(getBlockRichText(b, "heading_1"))}`);
|
|
582
|
+
lines.push("");
|
|
583
|
+
break;
|
|
584
|
+
case "heading_2":
|
|
585
|
+
lines.push(`## ${richTextToMarkdown(getBlockRichText(b, "heading_2"))}`);
|
|
586
|
+
lines.push("");
|
|
587
|
+
break;
|
|
588
|
+
case "heading_3":
|
|
589
|
+
lines.push(`### ${richTextToMarkdown(getBlockRichText(b, "heading_3"))}`);
|
|
590
|
+
lines.push("");
|
|
591
|
+
break;
|
|
592
|
+
case "bulleted_list_item":
|
|
593
|
+
lines.push(`- ${richTextToMarkdown(getBlockRichText(b, "bulleted_list_item"))}`);
|
|
594
|
+
break;
|
|
595
|
+
case "numbered_list_item":
|
|
596
|
+
lines.push(`1. ${richTextToMarkdown(getBlockRichText(b, "numbered_list_item"))}`);
|
|
597
|
+
break;
|
|
598
|
+
case "code": {
|
|
599
|
+
const codeData = b.code;
|
|
600
|
+
const lang = codeData.language || "";
|
|
601
|
+
const text = richTextToMarkdown(
|
|
602
|
+
codeData.rich_text || []
|
|
603
|
+
);
|
|
604
|
+
lines.push(`\`\`\`${lang}`);
|
|
605
|
+
lines.push(text);
|
|
606
|
+
lines.push("```");
|
|
607
|
+
lines.push("");
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
case "quote":
|
|
611
|
+
lines.push(
|
|
612
|
+
richTextToMarkdown(getBlockRichText(b, "quote")).split("\n").map((l) => `> ${l}`).join("\n")
|
|
613
|
+
);
|
|
614
|
+
lines.push("");
|
|
615
|
+
break;
|
|
616
|
+
case "divider":
|
|
617
|
+
lines.push("---");
|
|
618
|
+
lines.push("");
|
|
619
|
+
break;
|
|
620
|
+
default:
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return lines.join("\n").trim();
|
|
625
|
+
}
|
|
626
|
+
function getBlockRichText(block, type) {
|
|
627
|
+
const data = block[type];
|
|
628
|
+
return data?.rich_text || [];
|
|
629
|
+
}
|
|
630
|
+
function parseInlineMarkdown(text) {
|
|
631
|
+
const items = [];
|
|
632
|
+
const regex = /(\*\*(.+?)\*\*|__(.+?)__|_(.+?)_|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\))/g;
|
|
633
|
+
let lastIndex = 0;
|
|
634
|
+
let match;
|
|
635
|
+
while ((match = regex.exec(text)) !== null) {
|
|
636
|
+
if (match.index > lastIndex) {
|
|
637
|
+
items.push({
|
|
638
|
+
type: "text",
|
|
639
|
+
text: { content: text.slice(lastIndex, match.index) }
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (match[2] || match[3]) {
|
|
643
|
+
items.push({
|
|
644
|
+
type: "text",
|
|
645
|
+
text: { content: match[2] || match[3] },
|
|
646
|
+
annotations: { bold: true }
|
|
647
|
+
});
|
|
648
|
+
} else if (match[4] || match[5]) {
|
|
649
|
+
items.push({
|
|
650
|
+
type: "text",
|
|
651
|
+
text: { content: match[4] || match[5] },
|
|
652
|
+
annotations: { italic: true }
|
|
653
|
+
});
|
|
654
|
+
} else if (match[6]) {
|
|
655
|
+
items.push({
|
|
656
|
+
type: "text",
|
|
657
|
+
text: { content: match[6] },
|
|
658
|
+
annotations: { code: true }
|
|
659
|
+
});
|
|
660
|
+
} else if (match[7] && match[8]) {
|
|
661
|
+
items.push({
|
|
662
|
+
type: "text",
|
|
663
|
+
text: { content: match[7], link: { url: match[8] } }
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
lastIndex = match.index + match[0].length;
|
|
667
|
+
}
|
|
668
|
+
if (lastIndex < text.length) {
|
|
669
|
+
items.push({
|
|
670
|
+
type: "text",
|
|
671
|
+
text: { content: text.slice(lastIndex) }
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (items.length === 0) {
|
|
675
|
+
items.push({ type: "text", text: { content: text } });
|
|
676
|
+
}
|
|
677
|
+
return items;
|
|
678
|
+
}
|
|
679
|
+
function richTextToMarkdown(richText) {
|
|
680
|
+
return richText.map((item) => {
|
|
681
|
+
let text = item.text.content;
|
|
682
|
+
const a = item.annotations;
|
|
683
|
+
if (a?.bold) text = `**${text}**`;
|
|
684
|
+
if (a?.italic) text = `_${text}_`;
|
|
685
|
+
if (a?.code) text = `\`${text}\``;
|
|
686
|
+
if (item.text.link?.url) text = `[${text}](${item.text.link.url})`;
|
|
687
|
+
return text;
|
|
688
|
+
}).join("");
|
|
689
|
+
}
|
|
690
|
+
function mapLanguage(lang) {
|
|
691
|
+
const map = {
|
|
692
|
+
js: "javascript",
|
|
693
|
+
ts: "typescript",
|
|
694
|
+
py: "python",
|
|
695
|
+
rb: "ruby",
|
|
696
|
+
sh: "bash",
|
|
697
|
+
yml: "yaml",
|
|
698
|
+
"": "plain text"
|
|
699
|
+
};
|
|
700
|
+
return map[lang] || lang;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/lib/sync-state.ts
|
|
704
|
+
import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
705
|
+
import { join as join4 } from "path";
|
|
706
|
+
var SYNC_STATE_FILE = ".sync-state.json";
|
|
707
|
+
function syncStatePath(vaultPath) {
|
|
708
|
+
return join4(vaultPath, SYNC_STATE_FILE);
|
|
709
|
+
}
|
|
710
|
+
async function readSyncState(vaultPath) {
|
|
711
|
+
try {
|
|
712
|
+
const raw = await readFile4(syncStatePath(vaultPath), "utf-8");
|
|
713
|
+
return JSON.parse(raw);
|
|
714
|
+
} catch {
|
|
715
|
+
return {
|
|
716
|
+
lastSyncAt: "",
|
|
717
|
+
databases: {},
|
|
718
|
+
entries: {}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function writeSyncState(vaultPath, state) {
|
|
723
|
+
await writeFile4(
|
|
724
|
+
syncStatePath(vaultPath),
|
|
725
|
+
JSON.stringify(state, null, 2) + "\n",
|
|
726
|
+
"utf-8"
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
function getEntry(state, localPath) {
|
|
730
|
+
return state.entries[localPath];
|
|
731
|
+
}
|
|
732
|
+
function setEntry(state, localPath, entry) {
|
|
733
|
+
state.entries[localPath] = entry;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/lib/notion-sync.ts
|
|
737
|
+
import { writeFile as writeFile5, readFile as readFile5 } from "fs/promises";
|
|
738
|
+
import { join as join5 } from "path";
|
|
739
|
+
async function ensureDatabases(client, config, state) {
|
|
740
|
+
const rootPageId = config.notion.rootPageId;
|
|
741
|
+
for (const category of config.categories) {
|
|
742
|
+
if (state.databases[category]) continue;
|
|
743
|
+
try {
|
|
744
|
+
const db = await createCategoryDatabase(client, rootPageId, category);
|
|
745
|
+
state.databases[category] = db.id;
|
|
746
|
+
} catch (err) {
|
|
747
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
748
|
+
if (!msg.includes("already exists")) {
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return state;
|
|
754
|
+
}
|
|
755
|
+
async function pushToNotion(client, config, options = {}) {
|
|
756
|
+
const report = { pushed: [], pulled: [], conflicts: [], errors: [] };
|
|
757
|
+
const state = await readSyncState(config.path);
|
|
758
|
+
await ensureDatabases(client, config, state);
|
|
759
|
+
const docs = await listDocuments(config.path);
|
|
760
|
+
for (const doc of docs) {
|
|
761
|
+
const dbId = state.databases[doc.category];
|
|
762
|
+
if (!dbId) {
|
|
763
|
+
report.errors.push(`${doc.path}: no Notion database for category "${doc.category}"`);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const existing = getEntry(state, doc.path);
|
|
767
|
+
const blocks = markdownToBlocks(doc.content);
|
|
768
|
+
try {
|
|
769
|
+
if (existing) {
|
|
770
|
+
if (doc.updated <= existing.localUpdatedAt) continue;
|
|
771
|
+
if (!options.dryRun) {
|
|
772
|
+
await updateNotionPage(client, existing.notionPageId, {
|
|
773
|
+
title: doc.title,
|
|
774
|
+
type: doc.frontmatter.type,
|
|
775
|
+
tags: doc.tags,
|
|
776
|
+
updated: doc.updated
|
|
777
|
+
});
|
|
778
|
+
await replacePageContent(client, existing.notionPageId, blocks);
|
|
779
|
+
}
|
|
780
|
+
setEntry(state, doc.path, {
|
|
781
|
+
...existing,
|
|
782
|
+
localUpdatedAt: doc.updated,
|
|
783
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
784
|
+
});
|
|
785
|
+
} else {
|
|
786
|
+
if (!options.dryRun) {
|
|
787
|
+
const page = await createNotionPage(client, dbId, doc.title, {
|
|
788
|
+
type: doc.frontmatter.type,
|
|
789
|
+
tags: doc.tags,
|
|
790
|
+
created: doc.created,
|
|
791
|
+
updated: doc.updated
|
|
792
|
+
}, blocks);
|
|
793
|
+
setEntry(state, doc.path, {
|
|
794
|
+
localPath: doc.path,
|
|
795
|
+
notionPageId: page.id,
|
|
796
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
797
|
+
localUpdatedAt: doc.updated,
|
|
798
|
+
notionUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
report.pushed.push(doc.path);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
report.errors.push(`${doc.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (!options.dryRun) {
|
|
808
|
+
state.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
809
|
+
await writeSyncState(config.path, state);
|
|
810
|
+
}
|
|
811
|
+
return report;
|
|
812
|
+
}
|
|
813
|
+
async function pullFromNotion(client, config, options = {}) {
|
|
814
|
+
const report = { pushed: [], pulled: [], conflicts: [], errors: [] };
|
|
815
|
+
const state = await readSyncState(config.path);
|
|
816
|
+
await ensureDatabases(client, config, state);
|
|
817
|
+
for (const [category, dbId] of Object.entries(state.databases)) {
|
|
818
|
+
try {
|
|
819
|
+
const response = await queryDatabase(client, dbId, state.lastSyncAt || void 0);
|
|
820
|
+
for (const page of response.results) {
|
|
821
|
+
const pageAny = page;
|
|
822
|
+
const pageId = pageAny.id;
|
|
823
|
+
const lastEdited = pageAny.last_edited_time;
|
|
824
|
+
const props = pageAny.properties;
|
|
825
|
+
const titleProp = props.Title;
|
|
826
|
+
const titleArr = titleProp?.title;
|
|
827
|
+
const title = titleArr?.[0]?.plain_text || "untitled";
|
|
828
|
+
const typeProp = props.Type;
|
|
829
|
+
const typeSelect = typeProp?.select;
|
|
830
|
+
const type = typeSelect?.name;
|
|
831
|
+
const tagsProp = props.Tags;
|
|
832
|
+
const tagsArr = tagsProp?.multi_select;
|
|
833
|
+
const tags = tagsArr?.map((t) => t.name) || [];
|
|
834
|
+
const existingEntry = Object.values(state.entries).find(
|
|
835
|
+
(e) => e.notionPageId === pageId
|
|
836
|
+
);
|
|
837
|
+
if (existingEntry) {
|
|
838
|
+
const localPath = join5(config.path, existingEntry.localPath);
|
|
839
|
+
let localRaw;
|
|
840
|
+
try {
|
|
841
|
+
localRaw = await readFile5(localPath, "utf-8");
|
|
842
|
+
} catch {
|
|
843
|
+
localRaw = "";
|
|
844
|
+
}
|
|
845
|
+
if (localRaw) {
|
|
846
|
+
const localDoc = parseDocument(localRaw, existingEntry.localPath, category);
|
|
847
|
+
if (localDoc.updated > existingEntry.localUpdatedAt && !options.preferNotion) {
|
|
848
|
+
report.conflicts.push(existingEntry.localPath);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!options.dryRun) {
|
|
853
|
+
const blocks = await getPageBlocks(client, pageId);
|
|
854
|
+
const content = blocksToMarkdown(blocks);
|
|
855
|
+
const fm = { title };
|
|
856
|
+
if (type) fm.type = type;
|
|
857
|
+
if (tags.length > 0) fm.tags = tags;
|
|
858
|
+
const raw = serializeDocument({ title, content, frontmatter: fm, tags });
|
|
859
|
+
await writeFile5(localPath, raw, "utf-8");
|
|
860
|
+
setEntry(state, existingEntry.localPath, {
|
|
861
|
+
...existingEntry,
|
|
862
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
863
|
+
notionUpdatedAt: lastEdited,
|
|
864
|
+
localUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
report.pulled.push(existingEntry.localPath);
|
|
868
|
+
} else {
|
|
869
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
870
|
+
const relPath = `${category}/${slug}.md`;
|
|
871
|
+
const filePath = join5(config.path, relPath);
|
|
872
|
+
if (!options.dryRun) {
|
|
873
|
+
const blocks = await getPageBlocks(client, pageId);
|
|
874
|
+
const content = blocksToMarkdown(blocks);
|
|
875
|
+
const fm = { title };
|
|
876
|
+
if (type) fm.type = type;
|
|
877
|
+
if (tags.length > 0) fm.tags = tags;
|
|
878
|
+
const raw = serializeDocument({ title, content, frontmatter: fm, tags });
|
|
879
|
+
await writeFile5(filePath, raw, "utf-8");
|
|
880
|
+
setEntry(state, relPath, {
|
|
881
|
+
localPath: relPath,
|
|
882
|
+
notionPageId: pageId,
|
|
883
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
884
|
+
localUpdatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
885
|
+
notionUpdatedAt: lastEdited
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
report.pulled.push(relPath);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} catch (err) {
|
|
892
|
+
report.errors.push(`${category}: ${err instanceof Error ? err.message : String(err)}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (!options.dryRun) {
|
|
896
|
+
state.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
897
|
+
await writeSyncState(config.path, state);
|
|
898
|
+
}
|
|
899
|
+
return report;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export {
|
|
903
|
+
MEMORY_TYPES,
|
|
904
|
+
TYPE_TO_CATEGORY,
|
|
905
|
+
DEFAULT_CATEGORIES,
|
|
906
|
+
readConfig,
|
|
907
|
+
writeConfig,
|
|
908
|
+
configExists,
|
|
909
|
+
parseDocument,
|
|
910
|
+
serializeDocument,
|
|
911
|
+
slugify,
|
|
912
|
+
initVault,
|
|
913
|
+
resolveVault,
|
|
914
|
+
storeDocument,
|
|
915
|
+
getDocument,
|
|
916
|
+
listDocuments,
|
|
917
|
+
deleteDocument,
|
|
918
|
+
searchDocuments,
|
|
919
|
+
getSession,
|
|
920
|
+
startSession,
|
|
921
|
+
updateCheckpoint,
|
|
922
|
+
endSession,
|
|
923
|
+
getRecentHandoffs,
|
|
924
|
+
createNotionClient,
|
|
925
|
+
markdownToBlocks,
|
|
926
|
+
blocksToMarkdown,
|
|
927
|
+
readSyncState,
|
|
928
|
+
writeSyncState,
|
|
929
|
+
pushToNotion,
|
|
930
|
+
pullFromNotion
|
|
931
|
+
};
|