@neuctra/notlix-mcp 1.0.0 → 2.0.1
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 +51 -26
- package/bin/notlix-mcp.js +79 -21
- package/package.json +13 -3
- package/src/authix.js +153 -0
- package/src/cli/prompt.js +27 -0
- package/src/cli/session.js +37 -0
- package/src/content.js +263 -0
- package/src/defaults.js +8 -0
- package/src/documents.js +133 -0
- package/src/http.js +6 -35
- package/src/index.js +7 -3
- package/src/server.js +185 -97
- package/src/shared/aiEdits.js +142 -0
- package/src/shared/contextPacks.js +133 -0
- package/src/shared/diagrams.js +451 -0
- package/src/shared/presentations.js +374 -0
- package/src/shared/records.js +32 -0
- package/src/shared/slideShapes.js +133 -0
- package/src/sharedText.js +61 -0
- package/src/connection.js +0 -15
- package/src/inbox.js +0 -60
- package/src/seal.js +0 -32
package/src/server.js
CHANGED
|
@@ -1,98 +1,185 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { AuthixError } from "./authix.js";
|
|
5
|
+
import {
|
|
6
|
+
ContentError,
|
|
7
|
+
DIAGRAM_SHAPE_IDS,
|
|
8
|
+
DOCUMENT_CATEGORIES,
|
|
9
|
+
NOTE_CATEGORIES,
|
|
10
|
+
TYPES,
|
|
11
|
+
TYPE_LABELS,
|
|
12
|
+
checkSize,
|
|
13
|
+
editRecord,
|
|
14
|
+
newRecord,
|
|
15
|
+
readableContent,
|
|
16
|
+
searchableText,
|
|
17
|
+
} from "./content.js";
|
|
5
18
|
|
|
6
19
|
// The Notlix MCP tools. Used by the local stdio command (bin/notlix-mcp.js) and
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
20
|
+
// the remote Worker (neuctra-notlix-mcp-http). Every call acts as the signed-in
|
|
21
|
+
// user through Authix. Nothing can be deleted, and the vault is out of reach.
|
|
22
|
+
|
|
23
|
+
export const VERSION = "2.0.0";
|
|
24
|
+
|
|
25
|
+
const type = z.enum(TYPES).describe('"note", "knowledge", "document", "presentation" or "diagram".');
|
|
26
|
+
const id = z.string().trim().min(1).max(100).describe("The item's id, from list_notlix_items.");
|
|
27
|
+
const title = z.string().trim().min(1).max(200);
|
|
28
|
+
|
|
29
|
+
const diagramNode = z.object({
|
|
30
|
+
id: z.string().trim().min(1).max(60).describe("Your own key for this node, used by connectors."),
|
|
31
|
+
text: z.string().max(500).describe("The text in the box."),
|
|
32
|
+
shape: z.enum(DIAGRAM_SHAPE_IDS).optional().describe(`Default "rect". Flowchart: rect, terminator, diamond, parallelogram, documentShape, subroutine, hexagon. Others: roundRect, ellipse, triangle, note, text, cylinder (database), cloud, container.`),
|
|
33
|
+
});
|
|
34
|
+
const diagramEdge = z.object({
|
|
35
|
+
from: z.string().trim().min(1).max(60).describe("A node key, or an existing node id when editing."),
|
|
36
|
+
to: z.string().trim().min(1).max(60),
|
|
37
|
+
label: z.string().max(200).optional(),
|
|
38
|
+
});
|
|
39
|
+
const diagramInput = z
|
|
40
|
+
.object({
|
|
41
|
+
nodes: z.array(diagramNode).max(150).default([]),
|
|
42
|
+
edges: z.array(diagramEdge).max(300).default([]),
|
|
43
|
+
})
|
|
44
|
+
.describe("Diagrams only: boxes and the connectors between them. They're laid out top to bottom for you.");
|
|
45
|
+
|
|
46
|
+
const FORMATS = `Formats by type:
|
|
47
|
+
- note: plain text.
|
|
48
|
+
- knowledge: markdown. [[Title]] links to another Knowledge item.
|
|
49
|
+
- document: markdown, turned into a formatted document. Raw HTML is shown as text.
|
|
50
|
+
- presentation: markdown with --- on its own line between slides. Start each slide with a # title, then short - bullets (about six per slide), and end a slide with a line starting Notes: for speaker notes. Images aren't supported.
|
|
51
|
+
- diagram: no content; use diagram.nodes and diagram.edges.`;
|
|
52
|
+
|
|
53
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
54
|
+
const refuse = (text) => ({ isError: true, content: [{ type: "text", text }] });
|
|
55
|
+
|
|
56
|
+
/** Turns expected failures into tool errors the AI can pass on. */
|
|
57
|
+
const guarded = (handler) => async (args) => {
|
|
43
58
|
try {
|
|
44
|
-
await
|
|
45
|
-
return null;
|
|
59
|
+
return await handler(args);
|
|
46
60
|
} catch (err) {
|
|
47
|
-
if (
|
|
48
|
-
|
|
61
|
+
if (err instanceof AuthixError || err instanceof ContentError) return refuse(err.message);
|
|
62
|
+
throw err;
|
|
49
63
|
}
|
|
50
|
-
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const day = (value) => (value ? String(value).slice(0, 10) : "unknown");
|
|
67
|
+
const shorten = (text, max) => (text.length > max ? `${text.slice(0, max).trimEnd()}…` : text);
|
|
51
68
|
|
|
52
|
-
/** An MCP server
|
|
53
|
-
export function createNotlixServer(
|
|
69
|
+
/** An MCP server for one signed-in user. `authix` is an AuthixClient with a session. */
|
|
70
|
+
export function createNotlixServer(authix) {
|
|
54
71
|
const server = new McpServer({ name: "neuctra-notlix", version: VERSION });
|
|
55
72
|
|
|
73
|
+
async function findItem(itemType, itemId) {
|
|
74
|
+
const record = (await authix.list(itemType)).find((item) => item.id === itemId);
|
|
75
|
+
if (!record) {
|
|
76
|
+
throw new ContentError(`No ${itemType} with id ${itemId} in ${TYPE_LABELS[itemType]}. Use list_notlix_items to find it.`);
|
|
77
|
+
}
|
|
78
|
+
return record;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
server.registerTool(
|
|
57
|
-
"
|
|
82
|
+
"list_notlix_items",
|
|
58
83
|
{
|
|
59
|
-
title: "
|
|
60
|
-
description:
|
|
84
|
+
title: "List Notlix items",
|
|
85
|
+
description:
|
|
86
|
+
"List or search the user's Notes, Knowledge, Documents, Presentations and Diagrams in Neuctra Notlix. Returns ids, titles and dates, newest first. Use read_notlix_item to read one. The vault (passwords, secure notes, API keys, files) isn't available.",
|
|
61
87
|
inputSchema: {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.enum(["knowledge", "document", "presentation", "note"])
|
|
66
|
-
.default("knowledge")
|
|
67
|
-
.describe('"knowledge" for markdown reference material, "document" for formatted markdown writing, "presentation" for slides written as markdown split by ---, "note" for plain text.'),
|
|
88
|
+
type: type.optional().describe("Only this type. Leave out for all five."),
|
|
89
|
+
query: z.string().trim().max(200).optional().describe("Words that must all appear in the title or text."),
|
|
90
|
+
limit: z.number().int().min(1).max(200).default(50),
|
|
68
91
|
},
|
|
69
|
-
annotations: { readOnlyHint:
|
|
92
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
70
93
|
},
|
|
71
|
-
async ({
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
guarded(async ({ type: only, query, limit }) => {
|
|
95
|
+
const types = only ? [only] : TYPES;
|
|
96
|
+
const words = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const itemType of types) {
|
|
99
|
+
for (const record of await authix.list(itemType)) {
|
|
100
|
+
const text = searchableText(itemType, record);
|
|
101
|
+
if (words.every((word) => text.includes(word))) found.push({ itemType, record });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
found.sort(
|
|
105
|
+
(a, b) =>
|
|
106
|
+
Number(Boolean(b.record.isPinned)) - Number(Boolean(a.record.isPinned)) ||
|
|
107
|
+
String(b.record.updatedAt || "").localeCompare(String(a.record.updatedAt || "")),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (found.length === 0) return ok(query ? `Nothing matches "${query}".` : "No items yet.");
|
|
111
|
+
const lines = found.slice(0, limit).map(({ itemType, record }) => {
|
|
112
|
+
const pin = record.isPinned ? " (pinned)" : "";
|
|
113
|
+
return `- ${itemType} ${record.id}: "${record.title || "Untitled"}"${pin}, updated ${day(record.updatedAt)}`;
|
|
114
|
+
});
|
|
115
|
+
const more = found.length > limit ? `\n${found.length - limit} more. Narrow it with type or query.` : "";
|
|
116
|
+
return ok(`${found.length} ${found.length === 1 ? "item" : "items"}:\n${lines.join("\n")}${more}`);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.registerTool(
|
|
121
|
+
"read_notlix_item",
|
|
122
|
+
{
|
|
123
|
+
title: "Read a Notlix item",
|
|
124
|
+
description:
|
|
125
|
+
"Read one item from Neuctra Notlix in full. Documents and presentations are shown as markdown-like text: # marks headings and slide titles, - marks bullets, and --- separates slides. Diagrams list their nodes and connectors with ids.",
|
|
126
|
+
inputSchema: { type, id },
|
|
127
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
82
128
|
},
|
|
129
|
+
guarded(async ({ type: itemType, id: itemId }) => {
|
|
130
|
+
const record = await findItem(itemType, itemId);
|
|
131
|
+
const meta = [`Type: ${itemType}`, `Id: ${record.id}`, `Updated: ${day(record.updatedAt)}`];
|
|
132
|
+
if (record.category) meta.push(`Category: ${record.category}`);
|
|
133
|
+
return ok(`# ${record.title || "Untitled"}\n${meta.join(" · ")}\n\n${readableContent(itemType, record)}`);
|
|
134
|
+
}),
|
|
83
135
|
);
|
|
84
136
|
|
|
85
137
|
server.registerTool(
|
|
86
|
-
"
|
|
138
|
+
"create_notlix_item",
|
|
87
139
|
{
|
|
88
|
-
title: "
|
|
89
|
-
description:
|
|
140
|
+
title: "Create a Notlix item",
|
|
141
|
+
description: `Create a new Note, Knowledge item, Document, Presentation or Diagram in the user's Neuctra Notlix account. Write a clear title and content that makes sense on its own, without this chat.
|
|
142
|
+
|
|
143
|
+
${FORMATS}
|
|
144
|
+
|
|
145
|
+
Never save passwords, API keys or other secrets: these items aren't encrypted. Secrets belong in the Notlix vault, which this can't reach.`,
|
|
90
146
|
inputSchema: {
|
|
91
|
-
|
|
147
|
+
type,
|
|
148
|
+
title: title.describe("Short, descriptive title."),
|
|
149
|
+
content: z.string().max(200_000).default("").describe("The body, in the format for its type."),
|
|
150
|
+
category: z
|
|
92
151
|
.string()
|
|
93
|
-
.
|
|
94
|
-
.
|
|
95
|
-
|
|
152
|
+
.optional()
|
|
153
|
+
.describe(`Notes: ${NOTE_CATEGORIES.join(", ")}. Documents: ${DOCUMENT_CATEGORIES.join(", ")}. Default "general".`),
|
|
154
|
+
diagram: diagramInput.optional(),
|
|
155
|
+
},
|
|
156
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
157
|
+
},
|
|
158
|
+
guarded(async (args) => {
|
|
159
|
+
if (args.type !== "diagram" && !args.content.trim()) return refuse("Give some content to save.");
|
|
160
|
+
const record = newRecord(args.type, args);
|
|
161
|
+
checkSize(record);
|
|
162
|
+
const saved = await authix.add(args.type, record);
|
|
163
|
+
const savedId = saved?.id ? ` Its id is ${saved.id}.` : "";
|
|
164
|
+
return ok(`Created "${record.title}" in ${TYPE_LABELS[args.type]}.${savedId}`);
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
server.registerTool(
|
|
169
|
+
"edit_notlix_item",
|
|
170
|
+
{
|
|
171
|
+
title: "Edit a Notlix item",
|
|
172
|
+
description: `Change an existing item in Neuctra Notlix: rename it, fix or reword text, add content at the end, or add to a diagram. Read it first with read_notlix_item. Items can't be deleted.
|
|
173
|
+
|
|
174
|
+
- edits: each find is copied exactly from the item's current text and replaced with replace. Every occurrence is replaced, so include enough words to match only the place you mean. For documents, copy words as they read, without # or - markers, and keep each find inside one paragraph and one style (a find can't cross bold or a link). For presentations, keep each find inside one bullet, table cell or notes field; for diagrams, inside one node or connector label. replace is plain text.
|
|
175
|
+
- append: new content at the end, in the format for the type (see create_notlix_item). Presentations get new slides. Diagrams can't take append; use diagram instead.
|
|
176
|
+
- diagram: new nodes and connectors to add. Connectors can name existing node ids from read_notlix_item.
|
|
177
|
+
|
|
178
|
+
Finds that don't match are listed in the answer, and nothing else is guessed.`,
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type,
|
|
181
|
+
id,
|
|
182
|
+
title: title.optional().describe("A new title."),
|
|
96
183
|
edits: z
|
|
97
184
|
.array(
|
|
98
185
|
z.object({
|
|
@@ -101,40 +188,41 @@ export function createNotlixServer(inbox) {
|
|
|
101
188
|
}),
|
|
102
189
|
)
|
|
103
190
|
.max(50)
|
|
104
|
-
.optional()
|
|
105
|
-
.describe("Find-and-replace changes, such as typo fixes or rewording."),
|
|
191
|
+
.optional(),
|
|
106
192
|
append: z.string().max(200_000).optional().describe("New content to add at the end."),
|
|
193
|
+
diagram: diagramInput.optional(),
|
|
107
194
|
},
|
|
108
195
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
109
196
|
},
|
|
110
|
-
async ({
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return refuse("Nothing to change. Give at least one edit or some text to append.");
|
|
115
|
-
}
|
|
116
|
-
if (ref.startsWith("ntx-dgm-") && append.trim()) {
|
|
117
|
-
return refuse("Diagrams can't take appended content. Use edits to change the text in shapes and connector labels.");
|
|
197
|
+
guarded(async ({ type: itemType, id: itemId, title: newTitle, edits = [], append = "", diagram }) => {
|
|
198
|
+
const addsToDiagram = Boolean(diagram?.nodes?.length || diagram?.edges?.length);
|
|
199
|
+
if (!newTitle && edits.length === 0 && !append.trim() && !addsToDiagram) {
|
|
200
|
+
return refuse("Nothing to change. Give a title, edits, append or diagram.");
|
|
118
201
|
}
|
|
119
202
|
if (edits.some(({ find, replace }) => find === replace)) {
|
|
120
203
|
return refuse("An edit's find and replace are the same, so it wouldn't change anything.");
|
|
121
204
|
}
|
|
205
|
+
if (diagram && itemType !== "diagram") return refuse("diagram only works on diagrams.");
|
|
122
206
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
137
|
-
|
|
207
|
+
const record = await findItem(itemType, itemId);
|
|
208
|
+
const { changes, missed } = editRecord(itemType, record, { title: newTitle, edits, append, diagram });
|
|
209
|
+
if (missed.length === edits.length && edits.length > 0 && !newTitle && !append.trim() && !addsToDiagram) {
|
|
210
|
+
return refuse(`None of the finds matched the text of "${record.title}": ${missed.map((find) => `"${shorten(find, 60)}"`).join(", ")}. Read the item again and copy the text exactly.`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
checkSize({ ...record, ...changes });
|
|
214
|
+
await authix.update(record.id, changes);
|
|
215
|
+
|
|
216
|
+
const done = [];
|
|
217
|
+
if (newTitle) done.push("renamed it");
|
|
218
|
+
if (edits.length > missed.length) done.push(`applied ${edits.length - missed.length} of ${edits.length} ${edits.length === 1 ? "edit" : "edits"}`);
|
|
219
|
+
if (append.trim()) done.push("added content at the end");
|
|
220
|
+
if (addsToDiagram) done.push(`added ${diagram.nodes?.length || 0} nodes and ${diagram.edges?.length || 0} connectors`);
|
|
221
|
+
const skipped = missed.length
|
|
222
|
+
? ` These finds didn't match and were skipped: ${missed.map((find) => `"${shorten(find, 60)}"`).join(", ")}.`
|
|
223
|
+
: "";
|
|
224
|
+
return ok(`Updated "${changes.title || record.title}": ${done.join(", ")}.${skipped}`);
|
|
225
|
+
}),
|
|
138
226
|
);
|
|
139
227
|
|
|
140
228
|
return server;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Copied from neuctra-notlix-client/lib/workspace/aiEdits.js by scripts/sync.mjs. Don't edit here.
|
|
2
|
+
|
|
3
|
+
// Applies text edits an AI sent through edit_in_notlix. The AI can't read the
|
|
4
|
+
// account, so each edit names exact text, and edits whose text isn't there are
|
|
5
|
+
// reported back to the user instead of guessed at.
|
|
6
|
+
//
|
|
7
|
+
// An AI can only edit an item the user turned AI editing on for. That gives the
|
|
8
|
+
// item a random reference, which the user hands to the AI. Edits carrying any
|
|
9
|
+
// other reference are refused, so an AI can't reach the user's other items.
|
|
10
|
+
//
|
|
11
|
+
// Every edit function returns the changed fields and the finds that didn't match.
|
|
12
|
+
|
|
13
|
+
import { markdownToSlides, normalizeSlides } from "./presentations.js";
|
|
14
|
+
|
|
15
|
+
// The prefix names the type, so the reference is the only thing the AI needs.
|
|
16
|
+
const REF_PREFIXES = {
|
|
17
|
+
knowledge: "kn",
|
|
18
|
+
document: "doc",
|
|
19
|
+
presentation: "ppt",
|
|
20
|
+
diagram: "dgm",
|
|
21
|
+
note: "note",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const AI_EDIT_REF_PATTERN = /^ntx-(kn|doc|ppt|dgm|note)-[0-9a-f]{16}$/;
|
|
25
|
+
|
|
26
|
+
export function newAiEditRef(type) {
|
|
27
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
28
|
+
return `ntx-${REF_PREFIXES[type]}-${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function typeOfAiEditRef(ref) {
|
|
32
|
+
const match = AI_EDIT_REF_PATTERN.exec(String(ref));
|
|
33
|
+
return match ? Object.keys(REF_PREFIXES).find((type) => REF_PREFIXES[type] === match[1]) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const replaceAll = (text, find, replace) => text.split(find).join(replace);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs each edit over a list of text fields. `fields` holds { get, set } pairs,
|
|
40
|
+
* so the same logic works for strings, note blocks, slide elements and shapes.
|
|
41
|
+
*/
|
|
42
|
+
function applyToFields(fields, edits) {
|
|
43
|
+
const missed = [];
|
|
44
|
+
for (const { find, replace } of edits) {
|
|
45
|
+
let found = false;
|
|
46
|
+
for (const field of fields) {
|
|
47
|
+
const text = field.get();
|
|
48
|
+
if (typeof text === "string" && text.includes(find)) {
|
|
49
|
+
field.set(replaceAll(text, find, replace));
|
|
50
|
+
found = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!found) missed.push(find);
|
|
54
|
+
}
|
|
55
|
+
return missed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const joinBlocks = (before, after) => (before.trim() ? `${before.trimEnd()}\n\n${after}` : after);
|
|
59
|
+
|
|
60
|
+
export function editKnowledge(record, { edits, append }) {
|
|
61
|
+
const doc = { content: typeof record.content === "string" ? record.content : "" };
|
|
62
|
+
const missed = applyToFields([{ get: () => doc.content, set: (v) => { doc.content = v; } }], edits);
|
|
63
|
+
if (append.trim()) doc.content = joinBlocks(doc.content, append.trim());
|
|
64
|
+
return { changes: { content: doc.content }, missed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function editNote(record, { edits, append }) {
|
|
68
|
+
let blocks;
|
|
69
|
+
if (Array.isArray(record.content)) blocks = record.content.map((block) => ({ ...block }));
|
|
70
|
+
else if (typeof record.content === "string" && record.content.trim()) {
|
|
71
|
+
blocks = [{ id: `blk_${Date.now()}`, type: "text", content: record.content }];
|
|
72
|
+
} else blocks = [];
|
|
73
|
+
|
|
74
|
+
const missed = applyToFields(
|
|
75
|
+
blocks.map((block) => ({ get: () => block.content, set: (v) => { block.content = v; } })),
|
|
76
|
+
edits,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (append.trim()) {
|
|
80
|
+
const last = [...blocks].reverse().find((block) => typeof block.content === "string");
|
|
81
|
+
if (last) last.content = joinBlocks(last.content, append.trim());
|
|
82
|
+
else blocks.push({ id: `blk_${Date.now()}`, type: "text", content: append.trim() });
|
|
83
|
+
}
|
|
84
|
+
return { changes: { content: blocks }, missed };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function editPresentation(record, { edits, append }) {
|
|
88
|
+
const slides = normalizeSlides(structuredClone(record.slides));
|
|
89
|
+
const fields = [];
|
|
90
|
+
for (const slide of slides) {
|
|
91
|
+
fields.push({ get: () => slide.notes, set: (v) => { slide.notes = v; } });
|
|
92
|
+
for (const element of slide.elements) {
|
|
93
|
+
if (typeof element.text === "string") {
|
|
94
|
+
fields.push({ get: () => element.text, set: (v) => { element.text = v; } });
|
|
95
|
+
}
|
|
96
|
+
for (const row of element.rows ?? []) {
|
|
97
|
+
row.forEach((_, i) => fields.push({ get: () => row[i], set: (v) => { row[i] = v; } }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const missed = applyToFields(fields, edits);
|
|
103
|
+
if (append.trim()) slides.push(...markdownToSlides(append));
|
|
104
|
+
return { changes: { slides }, missed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function editDiagram(record, { edits }) {
|
|
108
|
+
const nodes = (Array.isArray(record.nodes) ? record.nodes : []).map((node) => ({ ...node }));
|
|
109
|
+
const edges = (Array.isArray(record.edges) ? record.edges : []).map((edge) => ({ ...edge }));
|
|
110
|
+
const missed = applyToFields(
|
|
111
|
+
[
|
|
112
|
+
...nodes.map((node) => ({ get: () => node.text, set: (v) => { node.text = v; } })),
|
|
113
|
+
...edges.map((edge) => ({ get: () => edge.label, set: (v) => { edge.label = v; } })),
|
|
114
|
+
],
|
|
115
|
+
edits,
|
|
116
|
+
);
|
|
117
|
+
return { changes: { nodes, edges }, missed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Documents are HTML. Edits only touch text nodes, so the AI's text can't add
|
|
122
|
+
* markup, and a find that crosses formatting such as bold doesn't match.
|
|
123
|
+
* `toHtml` turns appended markdown into clean HTML (markdownToDocument).
|
|
124
|
+
*/
|
|
125
|
+
export async function editDocument(record, { edits, append }, toHtml) {
|
|
126
|
+
const { body } = new DOMParser().parseFromString(String(record.html || ""), "text/html");
|
|
127
|
+
|
|
128
|
+
const textNodes = [];
|
|
129
|
+
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
130
|
+
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
|
131
|
+
|
|
132
|
+
const missed = applyToFields(
|
|
133
|
+
textNodes.map((node) => ({ get: () => node.nodeValue, set: (v) => { node.nodeValue = v; } })),
|
|
134
|
+
edits,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let html = body.innerHTML;
|
|
138
|
+
if (append.trim()) html += (await toHtml(append)).html;
|
|
139
|
+
|
|
140
|
+
const { body: finalBody } = new DOMParser().parseFromString(html, "text/html");
|
|
141
|
+
return { changes: { html, text: finalBody.textContent }, missed };
|
|
142
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copied from neuctra-notlix-client/lib/workspace/contextPacks.js by scripts/sync.mjs. Don't edit here.
|
|
2
|
+
|
|
3
|
+
// AI Briefs (stored as `contextpack`): saved, reusable bundles of notes and knowledge for any AI.
|
|
4
|
+
//
|
|
5
|
+
// A pack stores references (source ids), not copies, so compiling one always
|
|
6
|
+
// uses the newest version of each document. Everything here is pure, so it
|
|
7
|
+
// runs the same in the app, in tests, and when syncing to the extension.
|
|
8
|
+
|
|
9
|
+
// Rough rule of thumb across models: about 4 characters per token.
|
|
10
|
+
const CHARS_PER_TOKEN = 4;
|
|
11
|
+
|
|
12
|
+
export const CONTEXT_LIMITS = [
|
|
13
|
+
{ max: 8000, label: "Fits any AI chat", tone: "success" },
|
|
14
|
+
{ max: 32000, label: "Fits most AI chats", tone: "info" },
|
|
15
|
+
{ max: 128000, label: "Large. Some AI chats may cut it off", tone: "warning" },
|
|
16
|
+
{ max: Infinity, label: "Too large for most AI chats. Remove some sources", tone: "destructive" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function estimateTokens(text) {
|
|
20
|
+
return Math.ceil((text || "").length / CHARS_PER_TOKEN);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function contextFit(tokens) {
|
|
24
|
+
return CONTEXT_LIMITS.find((limit) => tokens <= limit.max);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Flattens a Notes record's block content into markdown. */
|
|
28
|
+
export function noteBody(note) {
|
|
29
|
+
const blocks = Array.isArray(note?.content) ? note.content : [];
|
|
30
|
+
const body = blocks
|
|
31
|
+
.map((block) => (typeof block === "string" ? block : block?.content || ""))
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join("\n\n");
|
|
34
|
+
return body || (typeof note?.content === "string" ? note.content : "") || "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Names used as {{blanks}}, in first-seen order. */
|
|
38
|
+
export function extractVariables(text) {
|
|
39
|
+
const names = [];
|
|
40
|
+
const pattern = /\{\{\s*([\w][\w -]*?)\s*\}\}/g;
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = pattern.exec(text || ""))) {
|
|
43
|
+
if (!names.includes(match[1])) names.push(match[1]);
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fills {{blanks}}. A blank with no value is left visible, never erased. */
|
|
49
|
+
export function fillVariables(text, values = {}) {
|
|
50
|
+
return (text || "").replace(/\{\{\s*([\w][\w -]*?)\s*\}\}/g, (whole, name) => {
|
|
51
|
+
const value = values[name];
|
|
52
|
+
return typeof value === "string" && value.trim() ? value.trim() : whole;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PROFILE_FIELDS = [
|
|
57
|
+
["name", "Name"],
|
|
58
|
+
["role", "Role"],
|
|
59
|
+
["about", "About me"],
|
|
60
|
+
["tone", "Preferred tone"],
|
|
61
|
+
["language", "Language"],
|
|
62
|
+
["rules", "Always follow"],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function profileToMarkdown(profile) {
|
|
66
|
+
const lines = PROFILE_FIELDS.filter(([key]) => profile?.[key]?.trim()).map(
|
|
67
|
+
([key, label]) => `- **${label}:** ${profile[key].trim()}`,
|
|
68
|
+
);
|
|
69
|
+
return lines.length ? `## About me\n\n${lines.join("\n")}` : "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolves a pack's source references against the loaded documents. */
|
|
73
|
+
export function resolveSources(pack, library) {
|
|
74
|
+
const byKey = new Map(library.map((doc) => [`${doc.type}:${doc.id}`, doc]));
|
|
75
|
+
const docs = [];
|
|
76
|
+
let missing = 0;
|
|
77
|
+
(pack?.sources || []).forEach((source) => {
|
|
78
|
+
const doc = byKey.get(`${source.type}:${source.id}`);
|
|
79
|
+
if (doc) docs.push(doc);
|
|
80
|
+
else missing += 1;
|
|
81
|
+
});
|
|
82
|
+
return { docs, missing };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Compiles a pack into one markdown document, ready for any AI. */
|
|
86
|
+
export function compilePack(pack, { library = [], profile = null, values = {} } = {}) {
|
|
87
|
+
const { docs, missing } = resolveSources(pack, library);
|
|
88
|
+
const parts = [`# ${pack?.name || "Context"}`];
|
|
89
|
+
|
|
90
|
+
const profileText = pack?.includeProfile ? profileToMarkdown(profile) : "";
|
|
91
|
+
if (profileText) parts.push(profileText);
|
|
92
|
+
if (pack?.instructions?.trim()) parts.push(`## Instructions\n\n${pack.instructions.trim()}`);
|
|
93
|
+
docs.forEach((doc) => parts.push(`## ${doc.title || "Untitled"}\n\n${doc.body || ""}`.trimEnd()));
|
|
94
|
+
|
|
95
|
+
const template = parts.join("\n\n---\n\n");
|
|
96
|
+
const markdown = fillVariables(template, values);
|
|
97
|
+
return { template, markdown, missing, variables: extractVariables(template) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* -------------------------------------------------------------------------- */
|
|
101
|
+
/* Secret check */
|
|
102
|
+
/* -------------------------------------------------------------------------- */
|
|
103
|
+
|
|
104
|
+
const SECRET_PATTERNS = [
|
|
105
|
+
["AWS access key", /\bAKIA[0-9A-Z]{16}\b/g],
|
|
106
|
+
["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g],
|
|
107
|
+
["GitHub token", /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g],
|
|
108
|
+
["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g],
|
|
109
|
+
["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/g],
|
|
110
|
+
["Stripe key", /\b[sr]k_live_[0-9A-Za-z]{16,}\b/g],
|
|
111
|
+
["Authix secret key", /\b(?:sk|ak)_live_[0-9a-f]{8}_[0-9a-f]{32,}\b/g],
|
|
112
|
+
["AI provider key", /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/g],
|
|
113
|
+
["Private key", /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/g],
|
|
114
|
+
["JSON web token", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
|
|
115
|
+
["Database URL with password", /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@[^\s/]+/gi],
|
|
116
|
+
["Password or secret", /\b(?:password|passwd|pwd|secret|api[_ -]?key|access[_ -]?token)\s*[:=]\s*["']?[^\s"']{6,}/gi],
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function mask(value) {
|
|
120
|
+
if (value.length <= 8) return "•".repeat(value.length);
|
|
121
|
+
return `${value.slice(0, 4)}${"•".repeat(6)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Finds likely secrets, deduplicated. Returns masked previews only. */
|
|
125
|
+
export function scanSecrets(text) {
|
|
126
|
+
const found = new Map();
|
|
127
|
+
SECRET_PATTERNS.forEach(([label, pattern]) => {
|
|
128
|
+
for (const match of (text || "").matchAll(pattern)) {
|
|
129
|
+
if (!found.has(match[0])) found.set(match[0], { label, preview: mask(match[0]) });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return [...found.values()];
|
|
133
|
+
}
|