apple-notes-mcp 2.9.13 → 2.9.14
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 +18 -1
- package/build/index.js +287 -128
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -425,6 +425,7 @@ Searches for notes by title or content.
|
|
|
425
425
|
| `folder` | string | No | Limit search to a specific folder (supports nested paths like `"Work/Clients"`) |
|
|
426
426
|
| `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
|
|
427
427
|
| `limit` | number | No | Maximum number of results to return. **Defaults to 50** — a broad query reads several properties per match via AppleScript (~200ms/note), so an unbounded search over hundreds of matches can exceed Notes' 30s timeout and return an error instead of results. Pass a higher value to see more; the applied limit (and whether it truncated the results) is disclosed in the response. |
|
|
428
|
+
| `includeWordCount` | boolean | No | Add `wordCount` to each result (`null` when the note is locked or its body unreadable). A database content search already has the text. Otherwise the bodies are read in one batched read-only database query, never one AppleScript call per note; that needs Full Disk Access and also adds `matchedIn`. Without it, the response carries `wordCountUnavailable` and the results are unchanged. |
|
|
428
429
|
|
|
429
430
|
**Example - Search titles:**
|
|
430
431
|
```json
|
|
@@ -453,6 +454,17 @@ Searches for notes by title or content.
|
|
|
453
454
|
|
|
454
455
|
**Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc. A content search also returns `source` (`"database"` or `"applescript"`), and `scanTruncated` when the database path left older notes unsearched.
|
|
455
456
|
|
|
457
|
+
When the note text came from the database (a database content search, or any search with `includeWordCount`), each result also carries `matchedIn`: `["title"]`, `["body"]`, or `["title", "body"]`, saying where the query text occurs. The body is the text after the first line. `matchedIn` is absent for locked notes and for AppleScript searches without `includeWordCount`. The text output appends the same details to each line, for example `· matched in title, body · 245 words`.
|
|
458
|
+
|
|
459
|
+
**Example - Content search with word counts:**
|
|
460
|
+
```json
|
|
461
|
+
{
|
|
462
|
+
"query": "budget",
|
|
463
|
+
"searchContent": true,
|
|
464
|
+
"includeWordCount": true
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
456
468
|
---
|
|
457
469
|
|
|
458
470
|
#### `query-notes`
|
|
@@ -470,6 +482,7 @@ can match titles and bodies together.
|
|
|
470
482
|
| `limit` | number | No | Maximum notes to return. Defaults to 50, maximum 500. The response reports the total match count. |
|
|
471
483
|
| `scanLimit` | number | No | How many of the most recently modified notes to examine. Defaults to 500, maximum 5000. The response says when older notes were left unscanned. |
|
|
472
484
|
| `includeDeleted` | boolean | No | Also scan notes in Recently Deleted, notes pending deletion, and folderless notes. Defaults to `false`. |
|
|
485
|
+
| `includeWordCount` | boolean | No | Add `wordCount` to each returned note, the same count `words:` filters on (`null` when locked or unreadable). Free when the query already reads bodies; a metadata-only query (for example `pinned`) reads just the returned notes' bodies in one extra read-only query. Defaults to `false`. |
|
|
473
486
|
|
|
474
487
|
**Syntax:**
|
|
475
488
|
|
|
@@ -514,7 +527,11 @@ Their snippets are always empty.
|
|
|
514
527
|
|
|
515
528
|
**Returns:** Matching notes, most recently modified first, each with `id`,
|
|
516
529
|
`title`, `folder`, `account`, `modified`, `created`, and a `snippet` centred on
|
|
517
|
-
the first matched phrase.
|
|
530
|
+
the first matched phrase. Each note also has `matchedIn` when the query has a
|
|
531
|
+
positive text term: `["title"]`, `["body"]`, or both, saying where those terms
|
|
532
|
+
occur (a `title:` term is only looked for in the title, a `body:` term only in
|
|
533
|
+
the body). It is absent for locked or undecodable notes, and an empty list
|
|
534
|
+
means the note matched through a non-text branch such as `pinned OR x`. The ids are the same `x-coredata://…/ICNote/p…` form
|
|
518
535
|
every other tool accepts. `structuredContent` also reports `matched` (total
|
|
519
536
|
matches), `scanned`, `eligible`, `scanTruncated`, `truncated`, and `unreadable`
|
|
520
537
|
(bodies that could not be decoded). A malformed query returns an error naming
|
package/build/index.js
CHANGED
|
@@ -46933,13 +46933,6 @@ function describeSearchScope(searchContent, resultCount) {
|
|
|
46933
46933
|
return "\n\n\u2139\uFE0F Only note titles were searched, so a term that appears in note bodies would not match. Retry with `searchContent: true` to search bodies instead.";
|
|
46934
46934
|
}
|
|
46935
46935
|
|
|
46936
|
-
// src/utils/noteQueryStore.ts
|
|
46937
|
-
import { execFileSync as execFileSync14 } from "child_process";
|
|
46938
|
-
import * as fs8 from "fs";
|
|
46939
|
-
import * as os8 from "os";
|
|
46940
|
-
import * as path8 from "path";
|
|
46941
|
-
import { gunzipSync as gunzipSync8 } from "zlib";
|
|
46942
|
-
|
|
46943
46936
|
// src/utils/noteQuery.ts
|
|
46944
46937
|
var QUERY_LIMITS = {
|
|
46945
46938
|
/** Maximum number of tokens (terms, operators, parentheses). */
|
|
@@ -47371,21 +47364,50 @@ function evaluateNoteQuery(node, note) {
|
|
|
47371
47364
|
}
|
|
47372
47365
|
}
|
|
47373
47366
|
}
|
|
47374
|
-
function
|
|
47367
|
+
function positiveTextPredicates(node, negated = false) {
|
|
47375
47368
|
switch (node.type) {
|
|
47376
47369
|
case "and":
|
|
47377
47370
|
case "or":
|
|
47378
|
-
return node.children.flatMap((child) =>
|
|
47371
|
+
return node.children.flatMap((child) => positiveTextPredicates(child, negated));
|
|
47379
47372
|
case "not":
|
|
47380
|
-
return
|
|
47373
|
+
return positiveTextPredicates(node.child, !negated);
|
|
47381
47374
|
case "text":
|
|
47382
|
-
return negated ? [] : [node.value];
|
|
47375
|
+
return negated ? [] : [{ field: node.field, value: node.value }];
|
|
47383
47376
|
default:
|
|
47384
47377
|
return [];
|
|
47385
47378
|
}
|
|
47386
47379
|
}
|
|
47380
|
+
function positiveTextTerms(node, negated = false) {
|
|
47381
|
+
return positiveTextPredicates(node, negated).map((predicate) => predicate.value);
|
|
47382
|
+
}
|
|
47383
|
+
function matchLocations(predicates, title, text2) {
|
|
47384
|
+
if (predicates.length === 0) return void 0;
|
|
47385
|
+
if (text2 === null && predicates.some((p) => p.field !== "title")) return void 0;
|
|
47386
|
+
const firstBreak = text2 === null ? -1 : text2.indexOf("\n");
|
|
47387
|
+
const titleLower = normalizeForMatch(title);
|
|
47388
|
+
const firstLineLower = text2 === null ? "" : normalizeForMatch(firstBreak === -1 ? text2 : text2.slice(0, firstBreak));
|
|
47389
|
+
const bodyLower = text2 === null || firstBreak === -1 ? "" : normalizeForMatch(text2.slice(firstBreak + 1));
|
|
47390
|
+
let inTitle = false;
|
|
47391
|
+
let inBody = false;
|
|
47392
|
+
for (const predicate of predicates) {
|
|
47393
|
+
const needle = normalizeForMatch(predicate.value);
|
|
47394
|
+
if (predicate.field !== "body" && !inTitle) {
|
|
47395
|
+
inTitle = titleLower.includes(needle) || firstLineLower.includes(needle);
|
|
47396
|
+
}
|
|
47397
|
+
if (predicate.field !== "title" && !inBody) inBody = bodyLower.includes(needle);
|
|
47398
|
+
}
|
|
47399
|
+
const locations = [];
|
|
47400
|
+
if (inTitle) locations.push("title");
|
|
47401
|
+
if (inBody) locations.push("body");
|
|
47402
|
+
return locations;
|
|
47403
|
+
}
|
|
47387
47404
|
|
|
47388
47405
|
// src/utils/noteQueryStore.ts
|
|
47406
|
+
import { execFileSync as execFileSync14 } from "child_process";
|
|
47407
|
+
import * as fs8 from "fs";
|
|
47408
|
+
import * as os8 from "os";
|
|
47409
|
+
import * as path8 from "path";
|
|
47410
|
+
import { gunzipSync as gunzipSync8 } from "zlib";
|
|
47389
47411
|
var NOTES_DB_PATH10 = path8.join(
|
|
47390
47412
|
os8.homedir(),
|
|
47391
47413
|
"Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
|
|
@@ -47467,6 +47489,15 @@ function decodeNoteBody(data) {
|
|
|
47467
47489
|
objectIds
|
|
47468
47490
|
};
|
|
47469
47491
|
}
|
|
47492
|
+
function decodeBodyHex(hex3) {
|
|
47493
|
+
try {
|
|
47494
|
+
return decodeNoteBody(
|
|
47495
|
+
new Uint8Array(gunzipSync8(Buffer.from(hex3, "hex"), { maxOutputLength: 32 * 1024 * 1024 }))
|
|
47496
|
+
);
|
|
47497
|
+
} catch {
|
|
47498
|
+
return null;
|
|
47499
|
+
}
|
|
47500
|
+
}
|
|
47470
47501
|
function countWords(text2) {
|
|
47471
47502
|
let count = 0;
|
|
47472
47503
|
for (const word of text2.replace(/\ufffc/gu, " ").split(/\s+/u)) {
|
|
@@ -47537,6 +47568,67 @@ function presentColumns2(dbPath2) {
|
|
|
47537
47568
|
}
|
|
47538
47569
|
return cols;
|
|
47539
47570
|
}
|
|
47571
|
+
function readStore(dbPath2, build) {
|
|
47572
|
+
if (!fs8.existsSync(dbPath2)) throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
|
|
47573
|
+
try {
|
|
47574
|
+
return runSqlite6(dbPath2, build(presentColumns2(dbPath2)));
|
|
47575
|
+
} catch (error2) {
|
|
47576
|
+
if (error2 instanceof NoteQueryStoreError) throw error2;
|
|
47577
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
47578
|
+
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
47579
|
+
throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
|
|
47580
|
+
}
|
|
47581
|
+
console.error(`query-notes: database read failed: ${message}`);
|
|
47582
|
+
throw new NoteQueryStoreError("Failed to read the Notes database.", "query_error");
|
|
47583
|
+
}
|
|
47584
|
+
}
|
|
47585
|
+
var NOTE_TEXT_BATCH_MAX = QUERY_RESULTS.MAX;
|
|
47586
|
+
function buildNoteTextsSql(available, pks) {
|
|
47587
|
+
const missing = ["Z_PK", "Z_ENT"].filter((c) => !available.has(c));
|
|
47588
|
+
if (missing.length) {
|
|
47589
|
+
throw new NoteQueryStoreError(
|
|
47590
|
+
`This macOS version's Notes database lacks columns search enrichment needs (${missing.join(", ")}).`,
|
|
47591
|
+
"schema"
|
|
47592
|
+
);
|
|
47593
|
+
}
|
|
47594
|
+
const keys = [...new Set(pks)];
|
|
47595
|
+
if (keys.length > NOTE_TEXT_BATCH_MAX) {
|
|
47596
|
+
throw new NoteQueryStoreError(
|
|
47597
|
+
`At most ${NOTE_TEXT_BATCH_MAX} notes can be read at once.`,
|
|
47598
|
+
"query_error"
|
|
47599
|
+
);
|
|
47600
|
+
}
|
|
47601
|
+
for (const pk of keys) {
|
|
47602
|
+
if (!Number.isSafeInteger(pk) || pk < 1) {
|
|
47603
|
+
throw new NoteQueryStoreError(`Invalid note key ${String(pk)}`, "query_error");
|
|
47604
|
+
}
|
|
47605
|
+
}
|
|
47606
|
+
const locked = available.has("ZISPASSWORDPROTECTED") ? "COALESCE(n.ZISPASSWORDPROTECTED, 0)" : "0";
|
|
47607
|
+
return [
|
|
47608
|
+
"BEGIN;",
|
|
47609
|
+
"SELECT json_object('k', 'meta', 'uuid', (SELECT Z_UUID FROM Z_METADATA LIMIT 1));",
|
|
47610
|
+
`SELECT json_object('k', 'note', 'pk', n.Z_PK, 'locked', ${locked}, 'data', (SELECT hex(d.ZDATA) FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK ORDER BY d.Z_PK DESC LIMIT 1)) FROM ZICCLOUDSYNCINGOBJECT n WHERE n.Z_ENT = ${entity2("ICNote")} AND n.Z_PK IN (${keys.length ? keys.join(", ") : "NULL"});`,
|
|
47611
|
+
"COMMIT;"
|
|
47612
|
+
].join(" ");
|
|
47613
|
+
}
|
|
47614
|
+
function readNoteTexts(pks, options = {}) {
|
|
47615
|
+
const output = readStore(
|
|
47616
|
+
options.dbPath ?? NOTES_DB_PATH10,
|
|
47617
|
+
(available) => buildNoteTextsSql(available, pks)
|
|
47618
|
+
);
|
|
47619
|
+
let uuid2;
|
|
47620
|
+
const texts = /* @__PURE__ */ new Map();
|
|
47621
|
+
for (const line of output.split("\n")) {
|
|
47622
|
+
if (!line.trim()) continue;
|
|
47623
|
+
const row = JSON.parse(line);
|
|
47624
|
+
if (row.k === "meta") uuid2 = typeof row.uuid === "string" ? row.uuid : void 0;
|
|
47625
|
+
else if (row.k === "note" && typeof row.pk === "number") {
|
|
47626
|
+
const body = row.locked || !row.data ? null : decodeBodyHex(row.data);
|
|
47627
|
+
texts.set(row.pk, body ? body.text : null);
|
|
47628
|
+
}
|
|
47629
|
+
}
|
|
47630
|
+
return { uuid: uuid2, texts };
|
|
47631
|
+
}
|
|
47540
47632
|
var escapeSegment = escapeFolderName;
|
|
47541
47633
|
function resolveFolders(rows) {
|
|
47542
47634
|
const byPk = new Map(rows.map((row) => [row.pk, row]));
|
|
@@ -47593,23 +47685,10 @@ function runNoteQuery(ast, options = {}) {
|
|
|
47593
47685
|
const withBodies = needsContent(ast);
|
|
47594
47686
|
const withTags = needsTags(ast);
|
|
47595
47687
|
const dbPath2 = options.dbPath ?? NOTES_DB_PATH10;
|
|
47596
|
-
|
|
47597
|
-
|
|
47598
|
-
|
|
47599
|
-
|
|
47600
|
-
output = runSqlite6(
|
|
47601
|
-
dbPath2,
|
|
47602
|
-
buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
|
|
47603
|
-
);
|
|
47604
|
-
} catch (error2) {
|
|
47605
|
-
if (error2 instanceof NoteQueryStoreError) throw error2;
|
|
47606
|
-
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
47607
|
-
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
47608
|
-
throw new NoteQueryStoreError(QUERY_FDA_MESSAGE, "no_fda");
|
|
47609
|
-
}
|
|
47610
|
-
console.error(`query-notes: database read failed: ${message}`);
|
|
47611
|
-
throw new NoteQueryStoreError("Failed to read the Notes database.", "query_error");
|
|
47612
|
-
}
|
|
47688
|
+
const output = readStore(
|
|
47689
|
+
dbPath2,
|
|
47690
|
+
(available) => buildScanSql(available, { scanLimit, includeDeleted, withBodies, withTags })
|
|
47691
|
+
);
|
|
47613
47692
|
let uuid2;
|
|
47614
47693
|
let eligible = 0;
|
|
47615
47694
|
const folderRows = [];
|
|
@@ -47633,6 +47712,8 @@ function runNoteQuery(ast, options = {}) {
|
|
|
47633
47712
|
}
|
|
47634
47713
|
const folders = resolveFolders(folderRows);
|
|
47635
47714
|
const textTerms = positiveTextTerms(ast);
|
|
47715
|
+
const textPredicates = positiveTextPredicates(ast);
|
|
47716
|
+
const pendingWordCounts = [];
|
|
47636
47717
|
const hits = [];
|
|
47637
47718
|
let matched = 0;
|
|
47638
47719
|
let unreadable = 0;
|
|
@@ -47650,15 +47731,7 @@ function runNoteQuery(ast, options = {}) {
|
|
|
47650
47731
|
if (withBodies) unreadable++;
|
|
47651
47732
|
return decoded;
|
|
47652
47733
|
}
|
|
47653
|
-
|
|
47654
|
-
decoded = decodeNoteBody(
|
|
47655
|
-
new Uint8Array(
|
|
47656
|
-
gunzipSync8(Buffer.from(row.data, "hex"), { maxOutputLength: 32 * 1024 * 1024 })
|
|
47657
|
-
)
|
|
47658
|
-
);
|
|
47659
|
-
} catch {
|
|
47660
|
-
decoded = null;
|
|
47661
|
-
}
|
|
47734
|
+
decoded = decodeBodyHex(row.data);
|
|
47662
47735
|
if (!decoded) unreadable++;
|
|
47663
47736
|
return decoded;
|
|
47664
47737
|
};
|
|
@@ -47699,6 +47772,8 @@ function runNoteQuery(ast, options = {}) {
|
|
|
47699
47772
|
matched++;
|
|
47700
47773
|
if (hits.length >= limit) continue;
|
|
47701
47774
|
const body = locked ? null : decode2();
|
|
47775
|
+
const matchedIn = matchLocations(textPredicates, row.title ?? "", body ? body.text : null);
|
|
47776
|
+
if (options.includeWordCount && !withBodies) pendingWordCounts.push(row.pk);
|
|
47702
47777
|
hits.push({
|
|
47703
47778
|
id: `x-coredata://${uuid2}/ICNote/p${row.pk}`,
|
|
47704
47779
|
title: row.title ?? "",
|
|
@@ -47707,7 +47782,16 @@ function runNoteQuery(ast, options = {}) {
|
|
|
47707
47782
|
...note.modified !== void 0 ? { modified: new Date(note.modified).toISOString() } : {},
|
|
47708
47783
|
...note.created !== void 0 ? { created: new Date(note.created).toISOString() } : {},
|
|
47709
47784
|
snippet: locked ? "" : body ? buildSnippet(body.text, textTerms) : (row.snippet ?? "").replace(/\s+/gu, " ").trim(),
|
|
47710
|
-
...locked ? { locked: true } : {}
|
|
47785
|
+
...locked ? { locked: true } : {},
|
|
47786
|
+
...matchedIn ? { matchedIn } : {},
|
|
47787
|
+
...options.includeWordCount && withBodies ? { wordCount: body ? countWords(body.text) : null } : {}
|
|
47788
|
+
});
|
|
47789
|
+
}
|
|
47790
|
+
if (pendingWordCounts.length) {
|
|
47791
|
+
const { texts } = readNoteTexts(pendingWordCounts, { dbPath: dbPath2 });
|
|
47792
|
+
pendingWordCounts.forEach((pk, index) => {
|
|
47793
|
+
const text2 = texts.get(pk);
|
|
47794
|
+
hits[index].wordCount = typeof text2 === "string" ? countWords(text2) : null;
|
|
47711
47795
|
});
|
|
47712
47796
|
}
|
|
47713
47797
|
return {
|
|
@@ -47750,6 +47834,7 @@ function searchContentViaDatabase(options) {
|
|
|
47750
47834
|
const result = runNoteQuery(buildSearchContentQuery(options), {
|
|
47751
47835
|
limit: options.limit,
|
|
47752
47836
|
scanLimit: QUERY_SCAN.MAX,
|
|
47837
|
+
includeWordCount: options.includeWordCount,
|
|
47753
47838
|
dbPath: options.dbPath
|
|
47754
47839
|
});
|
|
47755
47840
|
const notes = result.notes.map((hit) => ({
|
|
@@ -47760,7 +47845,9 @@ function searchContentViaDatabase(options) {
|
|
|
47760
47845
|
created: hit.created ? new Date(hit.created) : /* @__PURE__ */ new Date(0),
|
|
47761
47846
|
modified: hit.modified ? new Date(hit.modified) : /* @__PURE__ */ new Date(0),
|
|
47762
47847
|
...hit.folder !== void 0 ? { folder: hit.folder } : {},
|
|
47763
|
-
...hit.account !== void 0 ? { account: hit.account } : {}
|
|
47848
|
+
...hit.account !== void 0 ? { account: hit.account } : {},
|
|
47849
|
+
...hit.matchedIn ? { matchedIn: hit.matchedIn } : {},
|
|
47850
|
+
...hit.wordCount !== void 0 ? { wordCount: hit.wordCount } : {}
|
|
47764
47851
|
}));
|
|
47765
47852
|
return {
|
|
47766
47853
|
notes,
|
|
@@ -47783,6 +47870,55 @@ function contentSearchFailureHint(message, dbUnavailable) {
|
|
|
47783
47870
|
const remedy = dbUnavailable === "no_fda" ? " Grant Full Disk Access to the Node binary running this server (run the doctor tool for its path) so search-notes can search note bodies through the Notes database instead, which takes well under a second." : "";
|
|
47784
47871
|
return `${message} Body search through AppleScript scans every note body before the result limit applies, so a broad term can exceed the time budget on a large library.${remedy} Otherwise narrow the search with \`folder\` or \`modifiedSince\`, or use a more specific term.`;
|
|
47785
47872
|
}
|
|
47873
|
+
var NOTE_ID = /^x-coredata:\/\/([0-9A-Fa-f-]+)\/ICNote\/p(\d{1,15})$/;
|
|
47874
|
+
function addWordCountsFromDatabase(notes, query2, options = {}) {
|
|
47875
|
+
const keys = /* @__PURE__ */ new Map();
|
|
47876
|
+
for (const note of notes) {
|
|
47877
|
+
const match = NOTE_ID.exec(note.id ?? "");
|
|
47878
|
+
if (match) keys.set(note, { store: match[1].toUpperCase(), pk: Number(match[2]) });
|
|
47879
|
+
}
|
|
47880
|
+
const pks = [...new Set([...keys.values()].map((key) => key.pk))];
|
|
47881
|
+
let store;
|
|
47882
|
+
const texts = /* @__PURE__ */ new Map();
|
|
47883
|
+
try {
|
|
47884
|
+
for (let start = 0; start < pks.length; start += NOTE_TEXT_BATCH_MAX) {
|
|
47885
|
+
const read = readNoteTexts(pks.slice(start, start + NOTE_TEXT_BATCH_MAX), options);
|
|
47886
|
+
store = read.uuid?.toUpperCase();
|
|
47887
|
+
for (const [pk, text2] of read.texts) texts.set(pk, text2);
|
|
47888
|
+
}
|
|
47889
|
+
} catch (error2) {
|
|
47890
|
+
if (error2 instanceof NoteQueryStoreError) return { notes, unavailable: error2.kind };
|
|
47891
|
+
throw error2;
|
|
47892
|
+
}
|
|
47893
|
+
const predicates = [{ field: "any", value: query2 }];
|
|
47894
|
+
return {
|
|
47895
|
+
notes: notes.map((note) => {
|
|
47896
|
+
const key = keys.get(note);
|
|
47897
|
+
const text2 = key && key.store === store ? texts.get(key.pk) : void 0;
|
|
47898
|
+
if (typeof text2 !== "string") return { ...note, wordCount: null };
|
|
47899
|
+
const matchedIn = matchLocations(predicates, note.title, text2);
|
|
47900
|
+
return {
|
|
47901
|
+
...note,
|
|
47902
|
+
...matchedIn?.length ? { matchedIn } : {},
|
|
47903
|
+
wordCount: countWords(text2)
|
|
47904
|
+
};
|
|
47905
|
+
})
|
|
47906
|
+
};
|
|
47907
|
+
}
|
|
47908
|
+
function describeMatchDetails(hit) {
|
|
47909
|
+
const parts = [];
|
|
47910
|
+
if (hit.matchedIn) {
|
|
47911
|
+
parts.push(
|
|
47912
|
+
hit.matchedIn.length ? `matched in ${hit.matchedIn.join(", ")}` : "no text match (metadata)"
|
|
47913
|
+
);
|
|
47914
|
+
}
|
|
47915
|
+
if (hit.wordCount !== void 0) {
|
|
47916
|
+
parts.push(
|
|
47917
|
+
hit.wordCount === null ? "word count unavailable" : `${hit.wordCount} word${hit.wordCount === 1 ? "" : "s"}`
|
|
47918
|
+
);
|
|
47919
|
+
}
|
|
47920
|
+
return parts.map((part) => ` \xB7 ${part}`).join("");
|
|
47921
|
+
}
|
|
47786
47922
|
|
|
47787
47923
|
// src/tools/doctor.ts
|
|
47788
47924
|
import { spawnSync } from "child_process";
|
|
@@ -49333,7 +49469,7 @@ function selectParagraph(paragraphs, selector) {
|
|
|
49333
49469
|
);
|
|
49334
49470
|
return matches[occurrence - 1];
|
|
49335
49471
|
}
|
|
49336
|
-
var
|
|
49472
|
+
var NOTE_ID2 = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]{1,15})$/i;
|
|
49337
49473
|
function titleMatchSql(columns) {
|
|
49338
49474
|
return `SELECT json_object('pk', n.Z_PK, 'folder', n.ZFOLDER) FROM ZICCLOUDSYNCINGOBJECT n LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON f.Z_PK = n.ZFOLDER WHERE n.Z_ENT = ${entity("ICNote")} AND n.ZTITLE1 = CAST(@title AS TEXT) AND ${activeNoteSql(columns, "n", "f")} ORDER BY n.Z_PK;`;
|
|
49339
49475
|
}
|
|
@@ -49344,7 +49480,7 @@ function checkNoteSelector(selector) {
|
|
|
49344
49480
|
if (selector.folder !== void 0 && selector.title === void 0)
|
|
49345
49481
|
throw new ParagraphLinkError("invalid-argument", "folder only narrows a title lookup");
|
|
49346
49482
|
if (selector.id === void 0) return void 0;
|
|
49347
|
-
const pk =
|
|
49483
|
+
const pk = NOTE_ID2.exec(selector.id)?.[1];
|
|
49348
49484
|
if (!pk)
|
|
49349
49485
|
throw new ParagraphLinkError(
|
|
49350
49486
|
"invalid-argument",
|
|
@@ -56827,7 +56963,7 @@ registerTool(
|
|
|
56827
56963
|
registerTool(
|
|
56828
56964
|
"search-notes",
|
|
56829
56965
|
{
|
|
56830
|
-
description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id; `source` says whether a body search read the Notes database or fell back to AppleScript.\nDo not use when: you already have a note id (use get-note-content), want every note (use list-notes), or need boolean or metadata filters (use query-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.\nNote: with Full Disk Access, body search reads the Notes database (fast; the most recent 5000 notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on broad terms.",
|
|
56966
|
+
description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id; `source` says whether a body search read the Notes database or fell back to AppleScript. When the note text came from the database, each result has matchedIn (title, body, or both); includeWordCount adds wordCount.\nDo not use when: you already have a note id (use get-note-content), want every note (use list-notes), or need boolean or metadata filters (use query-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.\nNote: with Full Disk Access, body search reads the Notes database (fast; the most recent 5000 notes, Recently Deleted excluded); without it, it falls back to AppleScript, which scans every body and can time out on broad terms.",
|
|
56831
56967
|
inputSchema: {
|
|
56832
56968
|
query: external_exports.string().min(1, "Search query is required").max(MAX.QUERY),
|
|
56833
56969
|
searchContent: external_exports.boolean().optional().describe(
|
|
@@ -56840,112 +56976,132 @@ registerTool(
|
|
|
56840
56976
|
),
|
|
56841
56977
|
limit: external_exports.number().int().positive().optional().describe(
|
|
56842
56978
|
"Maximum number of results to return. Defaults to 50 \u2014 a broad query reads several properties per match via AppleScript, so an unbounded search can time out. Pass a higher value to see more; the applied limit is disclosed in the response."
|
|
56979
|
+
),
|
|
56980
|
+
includeWordCount: external_exports.boolean().optional().describe(
|
|
56981
|
+
"Add wordCount to each result (null when locked or unreadable). A database body search already has the text; otherwise the bodies are read in one batched read-only database query (needs Full Disk Access), which also adds matchedIn"
|
|
56843
56982
|
)
|
|
56844
56983
|
},
|
|
56845
56984
|
outputSchema: {
|
|
56846
56985
|
notes: external_exports.array(external_exports.object({}).passthrough()).optional(),
|
|
56847
56986
|
count: external_exports.number().optional(),
|
|
56848
56987
|
source: external_exports.enum(["database", "applescript"]).optional(),
|
|
56849
|
-
scanTruncated: external_exports.boolean().optional()
|
|
56988
|
+
scanTruncated: external_exports.boolean().optional(),
|
|
56989
|
+
wordCountUnavailable: external_exports.string().optional()
|
|
56850
56990
|
}
|
|
56851
56991
|
},
|
|
56852
|
-
withErrorHandling(
|
|
56853
|
-
|
|
56854
|
-
|
|
56855
|
-
|
|
56856
|
-
|
|
56857
|
-
|
|
56858
|
-
|
|
56859
|
-
|
|
56992
|
+
withErrorHandling(
|
|
56993
|
+
({ query: query2, searchContent = false, account, folder, modifiedSince, limit, includeWordCount }) => {
|
|
56994
|
+
const effectiveLimit = resolveSearchLimit(limit);
|
|
56995
|
+
const limitWasDefault = limit === void 0;
|
|
56996
|
+
let source;
|
|
56997
|
+
let dbScan;
|
|
56998
|
+
let dbUnavailable;
|
|
56999
|
+
const runSearch = () => {
|
|
57000
|
+
if (searchContent) {
|
|
57001
|
+
try {
|
|
57002
|
+
const db = searchContentViaDatabase({
|
|
57003
|
+
query: query2,
|
|
57004
|
+
account: notesManager.searchAccountScope(account),
|
|
57005
|
+
folder,
|
|
57006
|
+
modifiedSince,
|
|
57007
|
+
limit: effectiveLimit,
|
|
57008
|
+
includeWordCount
|
|
57009
|
+
});
|
|
57010
|
+
source = "database";
|
|
57011
|
+
dbScan = db.scan;
|
|
57012
|
+
return db.notes;
|
|
57013
|
+
} catch (error2) {
|
|
57014
|
+
if (!(error2 instanceof NoteQueryStoreError)) throw error2;
|
|
57015
|
+
dbUnavailable = error2.kind;
|
|
57016
|
+
}
|
|
57017
|
+
source = "applescript";
|
|
57018
|
+
}
|
|
56860
57019
|
try {
|
|
56861
|
-
|
|
56862
|
-
|
|
56863
|
-
|
|
57020
|
+
return notesManager.searchNotes(
|
|
57021
|
+
query2,
|
|
57022
|
+
searchContent,
|
|
57023
|
+
account,
|
|
56864
57024
|
folder,
|
|
56865
57025
|
modifiedSince,
|
|
56866
|
-
|
|
56867
|
-
|
|
56868
|
-
source = "database";
|
|
56869
|
-
dbScan = db.scan;
|
|
56870
|
-
return db.notes;
|
|
57026
|
+
effectiveLimit
|
|
57027
|
+
);
|
|
56871
57028
|
} catch (error2) {
|
|
56872
|
-
if (!(error2 instanceof
|
|
56873
|
-
|
|
57029
|
+
if (!searchContent || !(error2 instanceof Error)) throw error2;
|
|
57030
|
+
throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
|
|
56874
57031
|
}
|
|
56875
|
-
|
|
57032
|
+
};
|
|
57033
|
+
const {
|
|
57034
|
+
result: found,
|
|
57035
|
+
syncBefore,
|
|
57036
|
+
syncInterference
|
|
57037
|
+
} = withSyncAwarenessSync("search-notes", runSearch);
|
|
57038
|
+
let notes = found;
|
|
57039
|
+
let wordCountUnavailable;
|
|
57040
|
+
if (includeWordCount && source !== "database" && found.length > 0) {
|
|
57041
|
+
const enriched = addWordCountsFromDatabase(found, query2);
|
|
57042
|
+
notes = enriched.notes;
|
|
57043
|
+
wordCountUnavailable = enriched.unavailable;
|
|
57044
|
+
}
|
|
57045
|
+
const wordCountFields = wordCountUnavailable ? { wordCountUnavailable } : {};
|
|
57046
|
+
const wordCountNote = wordCountUnavailable ? `
|
|
57047
|
+
|
|
57048
|
+
\u2139\uFE0F Word counts were not added: ${wordCountUnavailable === "no_fda" ? "they are read from the Notes database, which needs Full Disk Access (run the doctor tool)" : "the Notes database could not be read"}.` : "";
|
|
57049
|
+
const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
|
|
57050
|
+
const sourceFields = source ? { source } : {};
|
|
57051
|
+
const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
|
|
57052
|
+
const scanNote = describeContentScan(dbScan);
|
|
57053
|
+
const folderInfo = folder ? ` in folder "${folder}"` : "";
|
|
57054
|
+
const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
|
|
57055
|
+
const { info: limitInfo, truncationNote } = describeSearchLimit(
|
|
57056
|
+
effectiveLimit,
|
|
57057
|
+
limitWasDefault,
|
|
57058
|
+
notes.length
|
|
57059
|
+
);
|
|
57060
|
+
const syncWarnings = [];
|
|
57061
|
+
if (syncBefore.syncDetected) {
|
|
57062
|
+
syncWarnings.push(`\u26A0\uFE0F iCloud sync was active during search.`);
|
|
56876
57063
|
}
|
|
56877
|
-
|
|
56878
|
-
|
|
56879
|
-
query2,
|
|
56880
|
-
searchContent,
|
|
56881
|
-
account,
|
|
56882
|
-
folder,
|
|
56883
|
-
modifiedSince,
|
|
56884
|
-
effectiveLimit
|
|
56885
|
-
);
|
|
56886
|
-
} catch (error2) {
|
|
56887
|
-
if (!searchContent || !(error2 instanceof Error)) throw error2;
|
|
56888
|
-
throw new Error(contentSearchFailureHint(error2.message, dbUnavailable));
|
|
57064
|
+
if (syncInterference) {
|
|
57065
|
+
syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
|
|
56889
57066
|
}
|
|
56890
|
-
|
|
56891
|
-
const {
|
|
56892
|
-
result: notes,
|
|
56893
|
-
syncBefore,
|
|
56894
|
-
syncInterference
|
|
56895
|
-
} = withSyncAwarenessSync("search-notes", runSearch);
|
|
56896
|
-
const searchType = searchContent ? source === "database" ? "content via the Notes database" : "content" : "titles";
|
|
56897
|
-
const sourceFields = source ? { source } : {};
|
|
56898
|
-
const scanFields = dbScan ? { scanTruncated: dbScan.scanTruncated } : {};
|
|
56899
|
-
const scanNote = describeContentScan(dbScan);
|
|
56900
|
-
const folderInfo = folder ? ` in folder "${folder}"` : "";
|
|
56901
|
-
const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
|
|
56902
|
-
const { info: limitInfo, truncationNote } = describeSearchLimit(
|
|
56903
|
-
effectiveLimit,
|
|
56904
|
-
limitWasDefault,
|
|
56905
|
-
notes.length
|
|
56906
|
-
);
|
|
56907
|
-
const syncWarnings = [];
|
|
56908
|
-
if (syncBefore.syncDetected) {
|
|
56909
|
-
syncWarnings.push(`\u26A0\uFE0F iCloud sync was active during search.`);
|
|
56910
|
-
}
|
|
56911
|
-
if (syncInterference) {
|
|
56912
|
-
syncWarnings.push(`\u26A0\uFE0F Sync activity detected - results may be incomplete.`);
|
|
56913
|
-
}
|
|
56914
|
-
const syncNote = syncWarnings.length > 0 ? `
|
|
57067
|
+
const syncNote = syncWarnings.length > 0 ? `
|
|
56915
57068
|
|
|
56916
57069
|
${syncWarnings.join(" ")}` : "";
|
|
56917
|
-
|
|
56918
|
-
|
|
57070
|
+
if (notes.length === 0) {
|
|
57071
|
+
const scopeHint = describeSearchScope(searchContent, notes.length);
|
|
57072
|
+
return successResponse(
|
|
57073
|
+
`No notes found matching "${query2}" in ${searchType}${folderInfo}${dateInfo}${scopeHint}${scanNote}${syncNote}`,
|
|
57074
|
+
{ notes: [], count: 0, ...sourceFields, ...scanFields }
|
|
57075
|
+
);
|
|
57076
|
+
}
|
|
57077
|
+
const noteList = notes.map((n) => {
|
|
57078
|
+
const idSuffix = `${n.id ? ` [id: ${n.id}]` : ""}${describeMatchDetails(n)}`;
|
|
57079
|
+
if (n.folder === "Recently Deleted") {
|
|
57080
|
+
return ` - ${n.title} [DELETED]${idSuffix}`;
|
|
57081
|
+
} else if (n.folder) {
|
|
57082
|
+
return ` - ${n.title} (${n.folder})${idSuffix}`;
|
|
57083
|
+
}
|
|
57084
|
+
return ` - ${n.title}${idSuffix}`;
|
|
57085
|
+
}).join("\n");
|
|
56919
57086
|
return successResponse(
|
|
56920
|
-
`
|
|
56921
|
-
|
|
57087
|
+
`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
|
|
57088
|
+
${noteList}${truncationNote}${scanNote}${wordCountNote}${syncNote}`,
|
|
57089
|
+
{
|
|
57090
|
+
notes: withStableIdentifiers(notes, "ICNote"),
|
|
57091
|
+
count: notes.length,
|
|
57092
|
+
...sourceFields,
|
|
57093
|
+
...scanFields,
|
|
57094
|
+
...wordCountFields
|
|
57095
|
+
}
|
|
56922
57096
|
);
|
|
56923
|
-
}
|
|
56924
|
-
|
|
56925
|
-
|
|
56926
|
-
if (n.folder === "Recently Deleted") {
|
|
56927
|
-
return ` - ${n.title} [DELETED]${idSuffix}`;
|
|
56928
|
-
} else if (n.folder) {
|
|
56929
|
-
return ` - ${n.title} (${n.folder})${idSuffix}`;
|
|
56930
|
-
}
|
|
56931
|
-
return ` - ${n.title}${idSuffix}`;
|
|
56932
|
-
}).join("\n");
|
|
56933
|
-
return successResponse(
|
|
56934
|
-
`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):
|
|
56935
|
-
${noteList}${truncationNote}${scanNote}${syncNote}`,
|
|
56936
|
-
{
|
|
56937
|
-
notes: withStableIdentifiers(notes, "ICNote"),
|
|
56938
|
-
count: notes.length,
|
|
56939
|
-
...sourceFields,
|
|
56940
|
-
...scanFields
|
|
56941
|
-
}
|
|
56942
|
-
);
|
|
56943
|
-
}, "Error searching notes")
|
|
57097
|
+
},
|
|
57098
|
+
"Error searching notes"
|
|
57099
|
+
)
|
|
56944
57100
|
);
|
|
56945
57101
|
registerTool(
|
|
56946
57102
|
"query-notes",
|
|
56947
57103
|
{
|
|
56948
|
-
description: 'Use when: finding notes with a boolean expression over text and metadata \u2014 e.g. `folder:Work has:checklist -checklist:done`, `(title:invoice OR tag:finance) modified:>=2026-07-01`, `pinned words:>250`. Reads the Notes database directly, so it is fast and can match title OR body in one call.\nSyntax: bare words and "quoted phrases" match title or body (case-insensitive substring); fields title:, body:, text:, folder:, account:, tag: (values may be quoted, e.g. folder:"Work Projects"); facets has:link|attachment|checklist|drawing|image|video|audio|pdf|table|scan|tag; checklist:open|done; flags pinned, locked, shared (or is:pinned); words:>250 and created:/modified: with =, >, >=, <, <= and YYYY-MM-DD local dates. AND is implicit; OR, NOT, leading -, and parentheses are supported; operators are case-insensitive and a quoted "and" searches the literal word.\nReturns: matching notes (most recently modified first) with id, title, folder, account, modified date, and
|
|
57104
|
+
description: 'Use when: finding notes with a boolean expression over text and metadata \u2014 e.g. `folder:Work has:checklist -checklist:done`, `(title:invoice OR tag:finance) modified:>=2026-07-01`, `pinned words:>250`. Reads the Notes database directly, so it is fast and can match title OR body in one call.\nSyntax: bare words and "quoted phrases" match title or body (case-insensitive substring); fields title:, body:, text:, folder:, account:, tag: (values may be quoted, e.g. folder:"Work Projects"); facets has:link|attachment|checklist|drawing|image|video|audio|pdf|table|scan|tag; checklist:open|done; flags pinned, locked, shared (or is:pinned); words:>250 and created:/modified: with =, >, >=, <, <= and YYYY-MM-DD local dates. AND is implicit; OR, NOT, leading -, and parentheses are supported; operators are case-insensitive and a quoted "and" searches the literal word.\nReturns: matching notes (most recently modified first) with id, title, folder, account, modified date, snippet, and matchedIn (where the positive text terms occur: title, body, or both; absent when the body is unreadable or the query has no text term), plus scan/match counts; includeWordCount adds wordCount. Ids work with get-note-content and every other id-based tool.\nDo not use when: Full Disk Access is unavailable (use search-notes). Scans the most recent scanLimit notes (default 500); raise it for older notes.\nSafety: read-only; never writes the database. Excludes Recently Deleted and folderless notes unless includeDeleted is true. Locked notes match on title and metadata only; body predicates never match them.',
|
|
56949
57105
|
inputSchema: {
|
|
56950
57106
|
query: external_exports.string().min(1, "A query expression is required").max(MAX.QUERY).describe(
|
|
56951
57107
|
'Boolean query expression, e.g. `folder:"Work Projects" has:checklist -checklist:done`'
|
|
@@ -56958,6 +57114,9 @@ registerTool(
|
|
|
56958
57114
|
),
|
|
56959
57115
|
includeDeleted: external_exports.boolean().optional().describe(
|
|
56960
57116
|
"Also scan notes in Recently Deleted, notes pending deletion, and folderless notes (default false)"
|
|
57117
|
+
),
|
|
57118
|
+
includeWordCount: external_exports.boolean().optional().describe(
|
|
57119
|
+
"Add wordCount to each returned note (the count words: filters on; null when locked or unreadable). Free when the query reads bodies; a metadata-only query reads just the returned notes' bodies in one extra query (default false)"
|
|
56961
57120
|
)
|
|
56962
57121
|
},
|
|
56963
57122
|
outputSchema: {
|
|
@@ -56974,10 +57133,10 @@ registerTool(
|
|
|
56974
57133
|
},
|
|
56975
57134
|
annotations: { readOnlyHint: true }
|
|
56976
57135
|
},
|
|
56977
|
-
withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted }) => {
|
|
57136
|
+
withErrorHandling(({ query: query2, limit, scanLimit, includeDeleted, includeWordCount }) => {
|
|
56978
57137
|
let result;
|
|
56979
57138
|
try {
|
|
56980
|
-
result = queryNotes(query2, { limit, scanLimit, includeDeleted });
|
|
57139
|
+
result = queryNotes(query2, { limit, scanLimit, includeDeleted, includeWordCount });
|
|
56981
57140
|
} catch (error2) {
|
|
56982
57141
|
if (error2 instanceof NoteQueryError || error2 instanceof NoteQueryStoreError) {
|
|
56983
57142
|
return errorResponse(
|
|
@@ -57008,7 +57167,7 @@ ${notes.join("\n")}` : "";
|
|
|
57008
57167
|
const where = [n.account, n.folder].filter(Boolean).join(" / ");
|
|
57009
57168
|
const snippet = n.snippet ? `
|
|
57010
57169
|
${n.snippet}` : "";
|
|
57011
|
-
return ` - ${n.title}${where ? ` (${where})` : ""}${n.locked ? " [locked]" : ""} [id: ${n.id}]${snippet}`;
|
|
57170
|
+
return ` - ${n.title}${where ? ` (${where})` : ""}${n.locked ? " [locked]" : ""} [id: ${n.id}]${describeMatchDetails(n)}${snippet}`;
|
|
57012
57171
|
}).join("\n");
|
|
57013
57172
|
return successResponse(
|
|
57014
57173
|
`Found ${result.matched} matching notes (${scope2}):
|
package/package.json
CHANGED