@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/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 { InboxError } from "./inbox.js";
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
- // by the remote Worker (neuctra-notlix-mcp-http). Items are encrypted here,
8
- // before they leave, so the Notlix server never sees the text. The write token
9
- // can add items and queue text edits for items the user turned AI editing on
10
- // for. It can't read anything.
11
-
12
- export const VERSION = "1.0.0";
13
-
14
- const TOOL_DESCRIPTION = `Save something to the user's Neuctra Notlix account so it isn't lost when this chat ends.
15
-
16
- Use this when the user asks you to save, remember or store something in Notlix. Write a clear title and a self-contained document that makes sense without this chat. Use type "knowledge" (markdown) for reference material, decisions and project context, type "document" (markdown) for finished, formatted writing the user will read, edit or export, such as reports, proposals and letters, type "presentation" for slide decks, and type "note" (plain text) for quick notes.
17
-
18
- For a presentation, write markdown with --- on its own line between slides. Start each slide with a # heading, follow it with short - bullets, and add a line starting with Notes: at the end of a slide for speaker notes. Keep each slide to about six short bullets. Images are not supported.
19
-
20
- Never save passwords, API keys, tokens or other secrets: Knowledge, Documents, Presentations and Notes are not encrypted in the account. This tool can only add new items. To change an item that already exists, use edit_in_notlix with the reference the user gives you. Nothing here can read or search Notlix.`;
21
-
22
- const EDIT_DESCRIPTION = `Edit an item in the user's Neuctra Notlix account that the user chose to share with you: fix typos, reword, or add more content at the end, such as new research.
23
-
24
- You can only edit an item the user gave you a Notlix reference for. It looks like ntx-doc-3f9a1c2b7d4e5f60, and the user copies it from Notlix together with the item's current text. Without a reference, ask the user to open the item in Notlix, choose "Edit with AI" and paste what it copies. Never make up a reference: edits with any other reference are refused.
25
-
26
- - edits: each find is copied exactly from the item's current text and replaced with replace. Every occurrence is replaced, so include enough surrounding words to match only the place you mean. For documents, copy the words as they read, without markdown symbols such as ** or #, and keep each find inside one paragraph and one style (a find can't cross bold or a link). For presentations and diagrams, keep each find inside one bullet, table cell, notes field, shape or connector label. replace is plain text.
27
- - append: new content added at the end. Markdown for knowledge (ntx-kn) and documents (ntx-doc), plain text for notes (ntx-note), and for presentations (ntx-ppt) new slides written as markdown with --- between slides, a # title and - bullets. Diagrams (ntx-dgm) can't take appended content.
28
-
29
- You can't delete items, rename them, or edit the encrypted vault. Edits are applied the next time the user opens Notlix. If a find doesn't match, that edit is skipped and the user is told, so you won't see an error here.`;
30
-
31
- // Matches newAiEditRef in the client. The prefix says what kind of item it is.
32
- const EDIT_REF = /^ntx-(kn|doc|ppt|dgm|note)-[0-9a-f]{16}$/;
33
-
34
- const DESTINATIONS = {
35
- knowledge: "Knowledge",
36
- document: "Documents",
37
- presentation: "Presentations",
38
- note: "Notes",
39
- };
40
-
41
- /** Sends an item. Returns a tool error result when it couldn't, or null. */
42
- async function send(inbox, item) {
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 inbox.add(item);
45
- return null;
59
+ return await handler(args);
46
60
  } catch (err) {
47
- if (!(err instanceof InboxError)) throw err;
48
- return { isError: true, content: [{ type: "text", text: err.message }] };
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 with save_to_notlix and edit_in_notlix. `inbox` comes from createInboxClient. */
53
- export function createNotlixServer(inbox) {
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
- "save_to_notlix",
82
+ "list_notlix_items",
58
83
  {
59
- title: "Save to Notlix",
60
- description: TOOL_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
- title: z.string().trim().min(1).max(200).describe("Short, descriptive title."),
63
- content: z.string().min(1).max(200_000).describe("The full text to save."),
64
- type: z
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: false, destructiveHint: false, openWorldHint: false },
92
+ annotations: { readOnlyHint: true, openWorldHint: false },
70
93
  },
71
- async ({ title, content, type }) => {
72
- const failed = await send(inbox, { title, content, type });
73
- if (failed) return failed;
74
- return {
75
- content: [
76
- {
77
- type: "text",
78
- text: `Saved "${title}". It will appear in ${DESTINATIONS[type]} the next time the user opens Notlix.`,
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
- "edit_in_notlix",
138
+ "create_notlix_item",
87
139
  {
88
- title: "Edit in Notlix",
89
- description: EDIT_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
- ref: z
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
- .trim()
94
- .regex(EDIT_REF, "Not a Notlix reference. Ask the user to copy one with Edit with AI in Notlix.")
95
- .describe("The Notlix reference the user gave you, such as ntx-doc-3f9a1c2b7d4e5f60."),
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 ({ ref, edits = [], append = "" }) => {
111
- const refuse = (text) => ({ isError: true, content: [{ type: "text", text }] });
112
-
113
- if (edits.length === 0 && !append.trim()) {
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 failed = await send(inbox, { action: "edit", ref, edits, append });
124
- if (failed) return failed;
125
-
126
- const parts = [];
127
- if (edits.length) parts.push(`${edits.length} ${edits.length === 1 ? "edit" : "edits"}`);
128
- if (append.trim()) parts.push("new content at the end");
129
- return {
130
- content: [
131
- {
132
- type: "text",
133
- text: `Queued ${parts.join(" and ")} for ${ref}. They will be applied the next time the user opens Notlix, if that item still allows AI edits. Any edit whose text doesn't match is skipped, and the user is told.`,
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
+ }