@openparachute/vault 0.7.3-rc.12 → 0.7.3-rc.2
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 +3 -5
- package/core/src/conformance.ts +1 -2
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/contract-typed-index.test.ts +3 -4
- package/core/src/core.test.ts +4 -521
- package/core/src/expand.ts +3 -11
- package/core/src/indexed-fields.test.ts +3 -9
- package/core/src/indexed-fields.ts +1 -9
- package/core/src/mcp.ts +10 -601
- package/core/src/notes.ts +4 -201
- package/core/src/schema-defaults.ts +1 -85
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/store.ts +3 -59
- package/core/src/tag-schemas.ts +12 -27
- package/core/src/types.ts +1 -7
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/cli.ts +1 -5
- package/src/contract-errors.test.ts +1 -2
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.ts +0 -17
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
package/core/src/seed-packs.ts
CHANGED
|
@@ -14,13 +14,6 @@
|
|
|
14
14
|
* surface (UI) over the vault. NOT default-seeded (ratified 2026-07-02:
|
|
15
15
|
* Surface Starter is out of the default seed) — added on demand via
|
|
16
16
|
* `parachute-vault add-pack surface-starter` or a console affordance.
|
|
17
|
-
* - `starter-ontology` — four starter **meta tags** (`view`, `archived`,
|
|
18
|
-
* `pinned`, `capture`) with constitution-style descriptions, plus seed
|
|
19
|
-
* `#view` notes mirroring the app's default pages (All notes, Recent,
|
|
20
|
-
* Pinned, Archive). NOT default-seeded (ratified 2026-07-16 design
|
|
21
|
-
* dialogue — deliberately opt-in; a fresh-vault materialization change
|
|
22
|
-
* needs its own decision) — added on demand via `parachute-vault
|
|
23
|
-
* add-pack starter-ontology` or a console affordance.
|
|
24
17
|
*
|
|
25
18
|
* Guides are the vault's skill files — the Parachute equivalent of a
|
|
26
19
|
* `SKILL.md`. They're tagged `#guide` (GUIDE_TAG): notes that teach how this
|
|
@@ -805,172 +798,6 @@ export const SURFACE_STARTER_PACK: SeedPack = {
|
|
|
805
798
|
],
|
|
806
799
|
};
|
|
807
800
|
|
|
808
|
-
// ---------------------------------------------------------------------------
|
|
809
|
-
// `starter-ontology` pack — four starter meta tags (opt-in)
|
|
810
|
-
// ---------------------------------------------------------------------------
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* The starter ontology, ratified in the 2026-07-16 design dialogue (Letter 1,
|
|
814
|
-
* Q5: "yes — tiny, opinionated, removable"): exactly four tags, no more.
|
|
815
|
-
* "Really want to start simple" / "don't be too prescriptive" — the vault
|
|
816
|
-
* ships the mechanisms; each parachute writes its own rules on top.
|
|
817
|
-
*
|
|
818
|
-
* A **meta tag** (the ratified user-facing term for "a tag with a schema")
|
|
819
|
-
* is `view` alone here — it carries `fields`. `archived`, `pinned`, and
|
|
820
|
-
* `capture` are plain tags: no schema, just a description that teaches the
|
|
821
|
-
* convention. Every description below is written as a constitution — it's
|
|
822
|
-
* read by both a human deciding whether to use the tag and an agent deciding
|
|
823
|
-
* how to behave around it — plain and warm, describing the DEFAULT
|
|
824
|
-
* convention rather than dictating one ("different people will draw that
|
|
825
|
-
* line differently").
|
|
826
|
-
*
|
|
827
|
-
* `capture` is NOT redeclared with new text — it's the exact `NOTES_REQUIRED_TAGS`
|
|
828
|
-
* entry, reused verbatim, because notes-ui's connect-time schema audit
|
|
829
|
-
* compares its `description` byte-for-byte (see the comment on
|
|
830
|
-
* `NOTES_REQUIRED_TAGS` above). Applying this pack must never be able to
|
|
831
|
-
* drift that string out from under the welcome pack, in either apply order.
|
|
832
|
-
*
|
|
833
|
-
* `pinned` shipped once before and was dropped as vestigial (PR #547,
|
|
834
|
-
* 2026-07-07): it was seeded onto `Welcome` with old sort-first semantics,
|
|
835
|
-
* which did nothing visible on a fresh vault (the Notes app had no list
|
|
836
|
-
* badge, and Welcome was already first). This is a different tag: the
|
|
837
|
-
* ratified semantics are partition-not-sort, it isn't stamped onto any
|
|
838
|
-
* existing note, and the pack ships a `Pinned` view that actually surfaces
|
|
839
|
-
* it.
|
|
840
|
-
*/
|
|
841
|
-
export const VIEW_TAG: SeedPackTag = {
|
|
842
|
-
name: "view",
|
|
843
|
-
description:
|
|
844
|
-
'A meta tag — it carries a schema, which is what makes a #view note a saved view rather than just a label. Tag a note #view and its metadata becomes the definition: `query` is the saved query itself (the same JSON shape query-notes accepts — tag, exclude_tags, search, metadata, order_by, ...), and `kind` says how to render the result (list, board, calendar, or gallery; an unrecognized or missing kind should degrade to list rather than fail — a view is never wrong to render as a list). `lane_by` names the metadata field a board\'s lanes come from; `date_field` names the date-typed field a calendar plots against — both only meaningful for their matching `kind`, and unset otherwise. The note\'s body is for people: what this view is for, when to reach for it. Because the definition lives in the vault as an ordinary note, any surface can read it and render the same view, and any agent can write one — "make me a board of my active drafts" is just creating a note. Authoring convention worth keeping: most views should write `exclude_tags: ["archived"]` into their own query rather than leaning on some surface-side default — an Archive view is the deliberate exception.',
|
|
845
|
-
fields: {
|
|
846
|
-
kind: {
|
|
847
|
-
type: "string",
|
|
848
|
-
description:
|
|
849
|
-
"How to render this view. Unrecognized or absent degrades to list — a view is never wrong to render as a list.",
|
|
850
|
-
enum: ["list", "board", "calendar", "gallery"],
|
|
851
|
-
default: "list",
|
|
852
|
-
},
|
|
853
|
-
query: {
|
|
854
|
-
type: "string",
|
|
855
|
-
description:
|
|
856
|
-
"The saved query — a JSON string of query-notes parameters (tag, exclude_tags, search, metadata, order_by, sort, ...). This is what actually determines which notes the view shows; kind only says how to draw them.",
|
|
857
|
-
},
|
|
858
|
-
lane_by: {
|
|
859
|
-
type: "string",
|
|
860
|
-
description:
|
|
861
|
-
"Board views only: the metadata field whose distinct values become the board's lanes/columns. Unset outside kind: board.",
|
|
862
|
-
},
|
|
863
|
-
date_field: {
|
|
864
|
-
type: "string",
|
|
865
|
-
description:
|
|
866
|
-
"Calendar views only: the date-typed metadata field the calendar plots notes against. Unset outside kind: calendar.",
|
|
867
|
-
},
|
|
868
|
-
},
|
|
869
|
-
};
|
|
870
|
-
|
|
871
|
-
export const ARCHIVED_TAG: SeedPackTag = {
|
|
872
|
-
name: "archived",
|
|
873
|
-
description:
|
|
874
|
-
"A plain tag — no schema, just a convention: this note is out of the flow of the present. Not deleted, not wrong, just done, closed, or set aside for now. The one behavior worth relying on: default views exclude archived notes unless a view's own query says otherwise (an Archive view, for instance, asks for them back on purpose). Archiving a note doesn't touch its content, tags, or links — it only changes whether it shows up by default. What actually gets archived, and when, is yours to decide: a finished project, last month's drafts, a conversation that's resolved. Different people will draw that line differently, and that's fine — the tag only promises the one thing above.",
|
|
875
|
-
};
|
|
876
|
-
|
|
877
|
-
export const PINNED_TAG: SeedPackTag = {
|
|
878
|
-
name: "pinned",
|
|
879
|
-
description:
|
|
880
|
-
"A plain tag — no schema, just a convention: this note gets surfaced first. Pinning is a partition, not a sort — a view that honors it renders pinned notes as their own small group above everything else, rather than nudging them up the existing order. That also means pinning doesn't fight with however the rest of a view is sorted; it just carves out a \"see this first\" spot above it. There's no built-in limit on how many notes can be pinned, and no rule about what belongs there — a current focus, something you keep needing to find, a note you're mid-thought on. Unpin by removing the tag. Different people, and different views, will use it differently; the tag only promises the placement, not the policy.",
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
export const VIEWS_PATH_PREFIX = "Views/";
|
|
884
|
-
export const ALL_NOTES_VIEW_PATH = `${VIEWS_PATH_PREFIX}All notes`;
|
|
885
|
-
export const RECENT_VIEW_PATH = `${VIEWS_PATH_PREFIX}Recent`;
|
|
886
|
-
export const PINNED_VIEW_PATH = `${VIEWS_PATH_PREFIX}Pinned`;
|
|
887
|
-
export const ARCHIVE_VIEW_PATH = `${VIEWS_PATH_PREFIX}Archive`;
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* Build the `starter-ontology` pack: the four meta tags above, plus four
|
|
891
|
-
* seed `#view` notes mirroring the app's default pages — All notes, Recent,
|
|
892
|
-
* Pinned, Archive — so "default app pages are shipped views" has its data
|
|
893
|
-
* half from day one. Each view's `query` is written out explicitly
|
|
894
|
-
* (`exclude_tags: ["archived"]` where it applies) rather than relying on any
|
|
895
|
-
* surface-side default, per the authoring convention in `VIEW_TAG`'s own
|
|
896
|
-
* description. Kept tiny on purpose — these are starting points, not a
|
|
897
|
-
* demonstration of everything the schema can do.
|
|
898
|
-
*
|
|
899
|
-
* NOT applied by `src/onboarding-seed.ts`'s default materialization — opt-in
|
|
900
|
-
* only, via `parachute-vault add-pack starter-ontology` or a console
|
|
901
|
-
* affordance. A fresh-vault UX change (making this part of the default seed)
|
|
902
|
-
* is a separate decision; this pack existing makes that a one-line flip
|
|
903
|
-
* later, deliberately not taken here.
|
|
904
|
-
*/
|
|
905
|
-
export const STARTER_ONTOLOGY_PACK: SeedPack = {
|
|
906
|
-
name: "starter-ontology",
|
|
907
|
-
description:
|
|
908
|
-
"The starter ontology: one meta tag (view, with a schema) and three plain tags (archived, pinned, capture), each with a constitution-style description — plus four seed #view notes (All notes, Recent, Pinned, Archive) mirroring the app's default pages. Opt-in — not seeded by default.",
|
|
909
|
-
// `capture` reused verbatim from NOTES_REQUIRED_TAGS (byte-equal to the
|
|
910
|
-
// welcome pack's — see the module doc above and NOTES_REQUIRED_TAGS'
|
|
911
|
-
// comment). Order matches the constitution ordering, not upsert order.
|
|
912
|
-
tags: [VIEW_TAG, ARCHIVED_TAG, PINNED_TAG, ...NOTES_REQUIRED_TAGS],
|
|
913
|
-
notes: [
|
|
914
|
-
{
|
|
915
|
-
path: ALL_NOTES_VIEW_PATH,
|
|
916
|
-
tags: ["view"],
|
|
917
|
-
content: `# All notes
|
|
918
|
-
|
|
919
|
-
Everything in the vault except what's archived. The default view — no tag
|
|
920
|
-
filter, no sort override, just the whole thing minus the notes you've set
|
|
921
|
-
aside.
|
|
922
|
-
`,
|
|
923
|
-
metadata: {
|
|
924
|
-
kind: "list",
|
|
925
|
-
query: JSON.stringify({ exclude_tags: ["archived"] }),
|
|
926
|
-
},
|
|
927
|
-
},
|
|
928
|
-
{
|
|
929
|
-
path: RECENT_VIEW_PATH,
|
|
930
|
-
tags: ["view"],
|
|
931
|
-
content: `# Recent
|
|
932
|
-
|
|
933
|
-
The vault, newest edits first. Same notes as [[${ALL_NOTES_VIEW_PATH}]] —
|
|
934
|
-
archived excluded — just ordered by when they last changed.
|
|
935
|
-
`,
|
|
936
|
-
metadata: {
|
|
937
|
-
kind: "list",
|
|
938
|
-
query: JSON.stringify({
|
|
939
|
-
exclude_tags: ["archived"],
|
|
940
|
-
order_by: "updated_at",
|
|
941
|
-
sort: "desc",
|
|
942
|
-
}),
|
|
943
|
-
},
|
|
944
|
-
},
|
|
945
|
-
{
|
|
946
|
-
path: PINNED_VIEW_PATH,
|
|
947
|
-
tags: ["view"],
|
|
948
|
-
content: `# Pinned
|
|
949
|
-
|
|
950
|
-
Whatever you've marked #pinned — the notes surfaced first, regardless of how
|
|
951
|
-
anything else in the vault is organized.
|
|
952
|
-
`,
|
|
953
|
-
metadata: {
|
|
954
|
-
kind: "list",
|
|
955
|
-
query: JSON.stringify({ tag: "pinned" }),
|
|
956
|
-
},
|
|
957
|
-
},
|
|
958
|
-
{
|
|
959
|
-
path: ARCHIVE_VIEW_PATH,
|
|
960
|
-
tags: ["view"],
|
|
961
|
-
content: `# Archive
|
|
962
|
-
|
|
963
|
-
Everything tagged #archived — out of the flow of the present, but never
|
|
964
|
-
gone. The one view that asks archived notes back on purpose.
|
|
965
|
-
`,
|
|
966
|
-
metadata: {
|
|
967
|
-
kind: "list",
|
|
968
|
-
query: JSON.stringify({ tag: "archived" }),
|
|
969
|
-
},
|
|
970
|
-
},
|
|
971
|
-
],
|
|
972
|
-
};
|
|
973
|
-
|
|
974
801
|
// ---------------------------------------------------------------------------
|
|
975
802
|
// Registry
|
|
976
803
|
// ---------------------------------------------------------------------------
|
|
@@ -980,7 +807,6 @@ export const SEED_PACK_NAMES = [
|
|
|
980
807
|
"welcome",
|
|
981
808
|
"getting-started",
|
|
982
809
|
"surface-starter",
|
|
983
|
-
"starter-ontology",
|
|
984
810
|
] as const;
|
|
985
811
|
|
|
986
812
|
export type SeedPackName = (typeof SEED_PACK_NAMES)[number];
|
|
@@ -1009,8 +835,6 @@ export function getSeedPack(
|
|
|
1009
835
|
return GETTING_STARTED_PACK;
|
|
1010
836
|
case "surface-starter":
|
|
1011
837
|
return SURFACE_STARTER_PACK;
|
|
1012
|
-
case "starter-ontology":
|
|
1013
|
-
return STARTER_ONTOLOGY_PACK;
|
|
1014
838
|
default:
|
|
1015
839
|
return null;
|
|
1016
840
|
}
|
|
@@ -1025,14 +849,6 @@ export interface ApplySeedPackResult {
|
|
|
1025
849
|
pack: string;
|
|
1026
850
|
/** Tag names upserted (upserts are idempotent; always every declared tag). */
|
|
1027
851
|
tags: string[];
|
|
1028
|
-
/**
|
|
1029
|
-
* Subset of `tags` whose DESCRIPTION was left untouched this run because it
|
|
1030
|
-
* had been hand-edited away from the pack's canonical text (see
|
|
1031
|
-
* `applySeedPack`'s description-preservation rule below). `fields` /
|
|
1032
|
-
* `parent_names` / `relationships` are still upserted unconditionally for
|
|
1033
|
-
* these tags — only the description write was skipped.
|
|
1034
|
-
*/
|
|
1035
|
-
preservedTagDescriptions: string[];
|
|
1036
852
|
/** Note paths written this run (absent ones). */
|
|
1037
853
|
seededNotes: string[];
|
|
1038
854
|
/** Note paths skipped because a note already lives there (idempotency). */
|
|
@@ -1045,30 +861,6 @@ export interface ApplySeedPackResult {
|
|
|
1045
861
|
* rows — so a re-run can never duplicate, and never clobbers a note the
|
|
1046
862
|
* operator/AI has since edited or recreated.
|
|
1047
863
|
*
|
|
1048
|
-
* **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
|
|
1049
|
-
* a tag's `description` only when (a) the tag has no prior description (new
|
|
1050
|
-
* tag, or an existing bare row nothing ever described), or (b) the prior
|
|
1051
|
-
* description is still byte-identical to the pack's own text — i.e. no one
|
|
1052
|
-
* has touched it. The moment a description differs from the pack's current
|
|
1053
|
-
* text, it's treated as a deliberate hand edit (a user-tuned "constitution")
|
|
1054
|
-
* and is never overwritten by a re-apply; omitting the `description` key from
|
|
1055
|
-
* the `upsertTagRecord` patch is what preserves it (the store keeps whatever
|
|
1056
|
-
* is already there). `fields` / `parent_names` / `relationships` are NOT
|
|
1057
|
-
* given this treatment here — they still upsert unconditionally, same as
|
|
1058
|
-
* before this change; those axes are more entangled with schema-validation
|
|
1059
|
-
* side effects (indexed-column lifecycle, cross-tag type agreement) to
|
|
1060
|
-
* safely split out in this pass. Description-only is the ratified scope.
|
|
1061
|
-
*
|
|
1062
|
-
* Known, accepted tradeoff: this compares against the pack's CURRENT text
|
|
1063
|
-
* only, not any prior canonical version. If a pack's canonical description
|
|
1064
|
-
* changes upstream, a vault that never touched it reads as "matches the OLD
|
|
1065
|
-
* text, therefore edited" from the new pack's point of view, and keeps the
|
|
1066
|
-
* old text too — same outcome as a genuine hand edit. That's fine:
|
|
1067
|
-
* constitutions aren't security patches that must propagate; a future
|
|
1068
|
-
* doctor-style scan can surface "description drifted from the current pack
|
|
1069
|
-
* text" as an informational finding without this applier needing to guess
|
|
1070
|
-
* intent. See CHANGELOG.
|
|
1071
|
-
*
|
|
1072
864
|
* Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
|
|
1073
865
|
* belong to the caller, which knows its failure policy.
|
|
1074
866
|
*
|
|
@@ -1083,7 +875,6 @@ export async function applySeedPack(
|
|
|
1083
875
|
const result: ApplySeedPackResult = {
|
|
1084
876
|
pack: pack.name,
|
|
1085
877
|
tags: [],
|
|
1086
|
-
preservedTagDescriptions: [],
|
|
1087
878
|
seededNotes: [],
|
|
1088
879
|
skippedNotes: [],
|
|
1089
880
|
};
|
|
@@ -1091,19 +882,12 @@ export async function applySeedPack(
|
|
|
1091
882
|
// Parents first so `parent_names` reads naturally in logs (the store accepts
|
|
1092
883
|
// forward references, but the order matches the conceptual model).
|
|
1093
884
|
for (const decl of pack.tags) {
|
|
1094
|
-
const existing = await store.getTagRecord(decl.name);
|
|
1095
|
-
const priorDescription = existing?.description ?? null;
|
|
1096
|
-
const isUserEdited = priorDescription != null && priorDescription !== decl.description;
|
|
1097
|
-
|
|
1098
885
|
await store.upsertTagRecord(decl.name, {
|
|
1099
|
-
|
|
1100
|
-
// treats an absent `description` key as "keep the current value".
|
|
1101
|
-
...(isUserEdited ? {} : { description: decl.description }),
|
|
886
|
+
description: decl.description,
|
|
1102
887
|
...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
|
|
1103
888
|
...(decl.fields ? { fields: decl.fields } : {}),
|
|
1104
889
|
});
|
|
1105
890
|
result.tags.push(decl.name);
|
|
1106
|
-
if (isUserEdited) result.preservedTagDescriptions.push(decl.name);
|
|
1107
891
|
}
|
|
1108
892
|
|
|
1109
893
|
for (const note of pack.notes) {
|
package/core/src/store.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
loadSchemaConfig,
|
|
39
39
|
validateNote as runValidateNote,
|
|
40
40
|
resolveNoteSchemas,
|
|
41
|
-
normalizeDateFields,
|
|
42
41
|
type ResolvedSchemas,
|
|
43
42
|
type ValidationStatus,
|
|
44
43
|
} from "./schema-defaults.js";
|
|
@@ -501,16 +500,6 @@ export class BunSqliteStore implements Store {
|
|
|
501
500
|
// ---- Notes ----
|
|
502
501
|
|
|
503
502
|
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note> {
|
|
504
|
-
// Normalize `date`-typed field values to canonical UTC ISO form BEFORE
|
|
505
|
-
// the write (vault#date-field-type — mixed-offset TEXT-compare
|
|
506
|
-
// corruption). COPY-ON-WRITE (round 2) — reassign the local `opts`
|
|
507
|
-
// binding from the returned value rather than mutating the caller's
|
|
508
|
-
// object; see `normalizeDateFields`'s doc comment.
|
|
509
|
-
if (opts?.metadata) {
|
|
510
|
-
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
511
|
-
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
503
|
const note = noteOps.createNote(this.db, content, opts);
|
|
515
504
|
|
|
516
505
|
if (content) {
|
|
@@ -559,20 +548,6 @@ export class BunSqliteStore implements Store {
|
|
|
559
548
|
actor?: string | null;
|
|
560
549
|
via?: string | null;
|
|
561
550
|
if_updated_at?: string;
|
|
562
|
-
/**
|
|
563
|
-
* `date`-field normalization override (vault#date-field-type review
|
|
564
|
-
* round 2). By default, `normalizeDateFields` below resolves the note's
|
|
565
|
-
* effective schema against its CURRENT tags in the DB — correct when
|
|
566
|
-
* this call doesn't also change tags. A caller that adds a tag IN THIS
|
|
567
|
-
* SAME logical update (via a separate `store.tagNote` issued right
|
|
568
|
-
* after this call — see mcp.ts's `update-note` handler and the batch
|
|
569
|
-
* upsert "update"/"replace" branch) must pass the PROJECTED final tag
|
|
570
|
-
* set here instead, or a newly-added tag's `type: "date"` field never
|
|
571
|
-
* gets seen (the schema resolution would still be looking at the
|
|
572
|
-
* pre-write tag set) and its offset-bearing value would persist
|
|
573
|
-
* verbatim. Ignored when `updates.metadata` is undefined.
|
|
574
|
-
*/
|
|
575
|
-
tagsForSchemaResolution?: string[];
|
|
576
551
|
},
|
|
577
552
|
): Promise<Note> {
|
|
578
553
|
let oldPath: string | undefined;
|
|
@@ -586,19 +561,7 @@ export class BunSqliteStore implements Store {
|
|
|
586
561
|
if (updates.path !== undefined || updates.metadata !== undefined) {
|
|
587
562
|
const existing = noteOps.getNote(this.db, id);
|
|
588
563
|
if (updates.path !== undefined) oldPath = existing?.path;
|
|
589
|
-
if (updates.metadata !== undefined)
|
|
590
|
-
priorMetadataForRefs = existing?.metadata;
|
|
591
|
-
// Normalize `date`-typed field values to canonical UTC ISO form
|
|
592
|
-
// BEFORE the write (vault#date-field-type — mixed-offset TEXT-
|
|
593
|
-
// compare corruption). COPY-ON-WRITE (round 2) — reassign the local
|
|
594
|
-
// `updates` binding from the returned value rather than mutating the
|
|
595
|
-
// caller's object; see `normalizeDateFields`'s doc comment.
|
|
596
|
-
// `tagsForSchemaResolution` (when the caller is ALSO adding a tag in
|
|
597
|
-
// this same logical update) wins over the note's current DB tags.
|
|
598
|
-
const tagsForResolution = updates.tagsForSchemaResolution ?? existing?.tags;
|
|
599
|
-
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: tagsForResolution, metadata: updates.metadata });
|
|
600
|
-
if (normalized !== updates.metadata) updates = { ...updates, metadata: normalized };
|
|
601
|
-
}
|
|
564
|
+
if (updates.metadata !== undefined) priorMetadataForRefs = existing?.metadata;
|
|
602
565
|
}
|
|
603
566
|
|
|
604
567
|
const note = noteOps.updateNote(this.db, id, updates);
|
|
@@ -1071,18 +1034,7 @@ export class BunSqliteStore implements Store {
|
|
|
1071
1034
|
// ---- Bulk Operations ----
|
|
1072
1035
|
|
|
1073
1036
|
async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
|
|
1074
|
-
|
|
1075
|
-
// (vault#date-field-type) — this bulk path bypasses it otherwise.
|
|
1076
|
-
// COPY-ON-WRITE (round 2) — build a new array with only the items that
|
|
1077
|
-
// actually need a rewrite replaced; never mutate the caller's `inputs`
|
|
1078
|
-
// or its element objects.
|
|
1079
|
-
const schemaConfig = this.getSchemaConfig();
|
|
1080
|
-
const normalizedInputs = inputs.map((input) => {
|
|
1081
|
-
if (!input.metadata) return input;
|
|
1082
|
-
const normalized = normalizeDateFields(schemaConfig, { tags: input.tags, metadata: input.metadata });
|
|
1083
|
-
return normalized !== input.metadata ? { ...input, metadata: normalized } : input;
|
|
1084
|
-
});
|
|
1085
|
-
const notes = noteOps.createNotes(this.db, normalizedInputs);
|
|
1037
|
+
const notes = noteOps.createNotes(this.db, inputs);
|
|
1086
1038
|
for (const note of notes) {
|
|
1087
1039
|
// Bulk path needs the same config-cache invalidation as singleton
|
|
1088
1040
|
// createNote — without it, a batch that includes `_tags/*` notes
|
|
@@ -1292,7 +1244,7 @@ export class BunSqliteStore implements Store {
|
|
|
1292
1244
|
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1293
1245
|
if (!mapped) {
|
|
1294
1246
|
throw new indexedFieldOps.IndexedFieldError(
|
|
1295
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference
|
|
1247
|
+
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
1296
1248
|
);
|
|
1297
1249
|
}
|
|
1298
1250
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
@@ -1424,14 +1376,6 @@ export class BunSqliteStore implements Store {
|
|
|
1424
1376
|
* place.)
|
|
1425
1377
|
*/
|
|
1426
1378
|
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
1427
|
-
// Same pre-write `date`-field normalization as createNote
|
|
1428
|
-
// (vault#date-field-type) — the legacy Obsidian importer (obsidian.ts)
|
|
1429
|
-
// funnels through this path and bypasses createNote's copy otherwise.
|
|
1430
|
-
// COPY-ON-WRITE (round 2) — see normalizeDateFields's doc comment.
|
|
1431
|
-
if (opts?.metadata) {
|
|
1432
|
-
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
1433
|
-
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
1434
|
-
}
|
|
1435
1379
|
return noteOps.createNote(this.db, content, opts);
|
|
1436
1380
|
}
|
|
1437
1381
|
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
19
|
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
20
20
|
import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
|
|
21
|
-
import { timestampToMs } from "./cursor.js";
|
|
22
21
|
|
|
23
22
|
// ---------------------------------------------------------------------------
|
|
24
23
|
// Types
|
|
@@ -193,7 +192,7 @@ export class InvalidFieldDefaultError extends Error {
|
|
|
193
192
|
|
|
194
193
|
/**
|
|
195
194
|
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
|
|
196
|
-
* field's declared `type` isn't one of the recognized values (vault#555
|
|
195
|
+
* field's declared `type` isn't one of the six recognized values (vault#555
|
|
197
196
|
* — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
|
|
198
197
|
* and persisted verbatim, no error, for any NON-indexed field: the only
|
|
199
198
|
* existing type check, `mapFieldType`, ran solely on `indexed: true` fields
|
|
@@ -256,13 +255,13 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
256
255
|
|
|
257
256
|
/**
|
|
258
257
|
* The full recognized vocabulary for `TagFieldSchema.type` — storage/
|
|
259
|
-
* advisory validation accepts all
|
|
260
|
-
* `reference
|
|
261
|
-
* `
|
|
262
|
-
*
|
|
263
|
-
* `
|
|
264
|
-
*
|
|
265
|
-
*
|
|
258
|
+
* advisory validation accepts all seven; only `string`/`integer`/`boolean`/
|
|
259
|
+
* `reference` are INDEXABLE (that narrower subset is `indexed-fields.ts`'s
|
|
260
|
+
* `TYPE_MAP`, enforced separately via `mapFieldType` for `indexed: true`
|
|
261
|
+
* fields). Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
|
|
262
|
+
* `SchemaField.type` union — kept in lockstep by hand across the two
|
|
263
|
+
* deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
|
|
264
|
+
* for why they don't cross-import).
|
|
266
265
|
*
|
|
267
266
|
* `reference` (vault#typed-reference-field) is a dual-write field type: the
|
|
268
267
|
* value is stored + validated exactly like `string` (an id/path/title the
|
|
@@ -271,18 +270,11 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
271
270
|
* to a note and maintains a graph `links` edge from this note to the
|
|
272
271
|
* resolved target, with `relationship` set to the field name. See
|
|
273
272
|
* `docs/design/typed-reference-field.md` for the full design + known gaps.
|
|
274
|
-
*
|
|
275
|
-
* `date` stores/validates like `string` (an ISO-8601 date or timestamp) so
|
|
276
|
-
* indexed `date` fields sort correctly under a plain TEXT comparison — see
|
|
277
|
-
* `defaultMatchesType`'s `"date"` case and `schema-defaults.ts`'s
|
|
278
|
-
* `valueMatchesType`, both of which reuse `cursor.ts`'s `timestampToMs`
|
|
279
|
-
* (the SAME UTC-correct parser `date_filter`'s `updated_at` bound uses) so
|
|
280
|
-
* there's exactly one ISO-parsing implementation in the codebase, not two.
|
|
281
273
|
*/
|
|
282
|
-
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference"
|
|
274
|
+
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference"] as const;
|
|
283
275
|
|
|
284
276
|
/**
|
|
285
|
-
* Validate that a field's declared `type` is one of the recognized
|
|
277
|
+
* Validate that a field's declared `type` is one of the six recognized
|
|
286
278
|
* values (vault#555). Returns `null` when `type` is unset (own-field checks
|
|
287
279
|
* elsewhere already treat an unset type as "nothing to check against") or
|
|
288
280
|
* recognized; otherwise a {@link TagFieldViolation} with `reason:
|
|
@@ -336,7 +328,7 @@ export function collectOwnFieldDefaultAndTypeViolations(
|
|
|
336
328
|
return violations;
|
|
337
329
|
}
|
|
338
330
|
|
|
339
|
-
/** Same
|
|
331
|
+
/** Same seven-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
340
332
|
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
341
333
|
switch (type) {
|
|
342
334
|
case "string":
|
|
@@ -356,13 +348,6 @@ function defaultMatchesType(value: unknown, type: string): boolean {
|
|
|
356
348
|
// VALID_FIELD_TYPES's doc comment.
|
|
357
349
|
case "reference":
|
|
358
350
|
return typeof value === "string";
|
|
359
|
-
// `date` accepts an ISO-8601 date (`YYYY-MM-DD`) or full RFC3339
|
|
360
|
-
// timestamp — the SAME grammar `date_filter`'s `updated_at` bound
|
|
361
|
-
// parses (`timestampToMs`, imported from cursor.ts), not a second,
|
|
362
|
-
// independently-drifting date parser. See VALID_FIELD_TYPES's doc
|
|
363
|
-
// comment.
|
|
364
|
-
case "date":
|
|
365
|
-
return typeof value === "string" && timestampToMs(value) !== null;
|
|
366
351
|
default:
|
|
367
352
|
return true;
|
|
368
353
|
}
|
|
@@ -782,7 +767,7 @@ export function collectTagFieldViolations(
|
|
|
782
767
|
violations.push({
|
|
783
768
|
field: fieldName,
|
|
784
769
|
reason: "unsupported_indexed_type",
|
|
785
|
-
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference
|
|
770
|
+
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
786
771
|
});
|
|
787
772
|
} else {
|
|
788
773
|
try {
|
package/core/src/types.ts
CHANGED
|
@@ -400,12 +400,6 @@ export interface NoteIndex {
|
|
|
400
400
|
metadata?: Record<string, unknown>;
|
|
401
401
|
byteSize: number;
|
|
402
402
|
preview: string;
|
|
403
|
-
/** Derived from the first non-empty line of content (title axis, ratified
|
|
404
|
-
* 2026-07-17) — NEVER stored, computed fresh at read time by
|
|
405
|
-
* `computeDisplayTitle`. `null` when content has no non-empty line.
|
|
406
|
-
* Surfaces decide how to render a `null` title (e.g. a timestamp/path
|
|
407
|
-
* fallback); core just reports the honest content-derived value. */
|
|
408
|
-
displayTitle: string | null;
|
|
409
403
|
/** Opt-in link degree (see `Note.linkCount`). */
|
|
410
404
|
linkCount?: number;
|
|
411
405
|
/** Full-text search relevance score (see `Note.score`). Carried onto the
|
|
@@ -456,7 +450,7 @@ export interface Store {
|
|
|
456
450
|
*/
|
|
457
451
|
getNoteByPath(path: string, extension?: string): Promise<Note | null>;
|
|
458
452
|
getNotes(ids: string[]): Promise<Note[]>;
|
|
459
|
-
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string
|
|
453
|
+
updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
|
|
460
454
|
/**
|
|
461
455
|
* Set a note's `created_at` and `updated_at` explicitly. Import-only:
|
|
462
456
|
* used by the portable-md round-trip path to restore timestamps from
|
|
@@ -272,49 +272,6 @@ function sqliteToUserType(t: string): string {
|
|
|
272
272
|
return t.toLowerCase();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// ---------------------------------------------------------------------------
|
|
276
|
-
// Attachments orientation block (attachment-tickets design, Wave 0+1)
|
|
277
|
-
// ---------------------------------------------------------------------------
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* The Attachments orientation block, rendered into the connect-time
|
|
281
|
-
* markdown brief (`projectionToMarkdown`) so every agent learns, every
|
|
282
|
-
* session, how to move file bytes into/out of this vault without spending
|
|
283
|
-
* its own context on them (bytes never ride MCP either way).
|
|
284
|
-
*
|
|
285
|
-
* `ticketsEnabled` reflects whether THIS door has wired an
|
|
286
|
-
* `AttachmentTicketProvider` (bun: always, as of the Wave 1 PR; cloud: not
|
|
287
|
-
* yet — its mirror is a separate PR). `readEnabled` reflects whether it's
|
|
288
|
-
* wired an `AttachmentBytesProvider` (bun: always, as of this PR — Wave 2).
|
|
289
|
-
* An unwired seam omits BOTH its tool(s) from `tools/list` (see
|
|
290
|
-
* `generateMcpTools`'s `attachmentTickets` / `attachmentBytes` opts) AND
|
|
291
|
-
* its sentence here — the brief never dangles a pointer at a tool the
|
|
292
|
-
* agent can't actually call.
|
|
293
|
-
*
|
|
294
|
-
* Kept as a single dense paragraph (not its own multi-line list) to stay
|
|
295
|
-
* inside the connect-time brief's token budget — see
|
|
296
|
-
* `projectionToMarkdown`'s doc comment.
|
|
297
|
-
*/
|
|
298
|
-
export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean; readEnabled?: boolean }): string {
|
|
299
|
-
const sentences: string[] = [
|
|
300
|
-
"Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP tool RESULTS unless you ask for them).",
|
|
301
|
-
];
|
|
302
|
-
if (opts.ticketsEnabled) {
|
|
303
|
-
sentences.push(
|
|
304
|
-
"To move bytes without spending your own context, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
if (opts.readEnabled) {
|
|
308
|
-
sentences.push(
|
|
309
|
-
"To read an attachment directly into this conversation, call `read-attachment` — text comes back as a paginated `content` slice, images as a real image you can see, audio/video as a transcript pointer (never raw bytes), and PDF/other binary formats point you at a download ticket instead.",
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
sentences.push(
|
|
313
|
-
"If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer` (honors a `Range: bytes=a-b` header for partial reads). Audio attached with `transcribe: true` is transcribed automatically.",
|
|
314
|
-
);
|
|
315
|
-
return sentences.join(" ");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
275
|
// ---------------------------------------------------------------------------
|
|
319
276
|
// Markdown rendering — for getServerInstruction
|
|
320
277
|
// ---------------------------------------------------------------------------
|
|
@@ -345,13 +302,6 @@ export function projectionToMarkdown(args: {
|
|
|
345
302
|
* known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
|
|
346
303
|
*/
|
|
347
304
|
coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
|
|
348
|
-
/**
|
|
349
|
-
* Attachments orientation (see `attachmentsInstructionBlock`). Omitted
|
|
350
|
-
* defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
|
|
351
|
-
* that hasn't wired ticket support yet (or a test fixture) never
|
|
352
|
-
* accidentally advertises tools it can't back.
|
|
353
|
-
*/
|
|
354
|
-
attachments?: { ticketsEnabled: boolean; readEnabled?: boolean };
|
|
355
305
|
}): string {
|
|
356
306
|
const { vaultName, description, projection, coordinates } = args;
|
|
357
307
|
const stats = projection.stats;
|
|
@@ -468,11 +418,6 @@ export function projectionToMarkdown(args: {
|
|
|
468
418
|
lines.push("");
|
|
469
419
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
470
420
|
|
|
471
|
-
lines.push("");
|
|
472
|
-
lines.push("## Attachments");
|
|
473
|
-
lines.push("");
|
|
474
|
-
lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
|
|
475
|
-
|
|
476
421
|
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
477
422
|
// querying — an agent had no path to "how do I script/automate against this
|
|
478
423
|
// vault." Point at the guide rather than inlining it, to keep this brief
|
package/package.json
CHANGED
package/src/add-pack.test.ts
CHANGED
|
@@ -140,35 +140,3 @@ describe("add-pack surface-starter", () => {
|
|
|
140
140
|
expect(stdout).toContain("~ tag capture (upserted)");
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
|
-
|
|
144
|
-
describe("add-pack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
|
|
145
|
-
test("a hand-edited description survives a re-apply and is reported 'kept', not 'upserted'", () => {
|
|
146
|
-
expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
|
|
147
|
-
|
|
148
|
-
// Simulate an operator/AI hand-editing the capture tag's constitution.
|
|
149
|
-
const dbPath = join(home, "vault", "data", "packed", "vault.db");
|
|
150
|
-
const editedDescription = "Our house rules for capture — not the default text.";
|
|
151
|
-
const writeDb = new Database(dbPath);
|
|
152
|
-
writeDb.query("UPDATE tags SET description = ? WHERE name = ?").run(
|
|
153
|
-
editedDescription,
|
|
154
|
-
"capture",
|
|
155
|
-
);
|
|
156
|
-
writeDb.close();
|
|
157
|
-
|
|
158
|
-
const { exitCode, stdout } = runCli(
|
|
159
|
-
["add-pack", "welcome", "--vault", "packed"],
|
|
160
|
-
{ PARACHUTE_HOME: home },
|
|
161
|
-
);
|
|
162
|
-
expect(exitCode).toBe(0);
|
|
163
|
-
expect(stdout).toContain("~ tag capture (kept user description)");
|
|
164
|
-
// guide wasn't edited, so it still reports the old vocabulary.
|
|
165
|
-
expect(stdout).toContain("~ tag guide (upserted)");
|
|
166
|
-
|
|
167
|
-
const readDb = new Database(dbPath, { readonly: true });
|
|
168
|
-
const row = readDb
|
|
169
|
-
.query("SELECT description FROM tags WHERE name = ?")
|
|
170
|
-
.get("capture") as { description: string };
|
|
171
|
-
readDb.close();
|
|
172
|
-
expect(row.description).toBe(editedDescription);
|
|
173
|
-
});
|
|
174
|
-
});
|
package/src/cli.ts
CHANGED
|
@@ -4842,11 +4842,7 @@ async function cmdAddPack(args: string[]) {
|
|
|
4842
4842
|
console.log(` = note ${path} (already exists — left untouched)`);
|
|
4843
4843
|
}
|
|
4844
4844
|
for (const tag of result.tags) {
|
|
4845
|
-
|
|
4846
|
-
console.log(` ~ tag ${tag} (kept user description)`);
|
|
4847
|
-
} else {
|
|
4848
|
-
console.log(` ~ tag ${tag} (upserted)`);
|
|
4849
|
-
}
|
|
4845
|
+
console.log(` ~ tag ${tag} (upserted)`);
|
|
4850
4846
|
}
|
|
4851
4847
|
if (result.seededNotes.length === 0 && result.tags.length === 0) {
|
|
4852
4848
|
console.log(" Nothing to add — everything was already in place.");
|
|
@@ -315,7 +315,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
315
315
|
expect(record?.fields ?? null).toBeFalsy();
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
-
// Positive control — every one of the recognized types (indexable or
|
|
318
|
+
// Positive control — every one of the seven recognized types (indexable or
|
|
319
319
|
// not) is accepted without complaint.
|
|
320
320
|
it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
|
|
321
321
|
const req = new Request("http://localhost/api/tags/widget", {
|
|
@@ -329,7 +329,6 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
329
329
|
e: { type: "array" },
|
|
330
330
|
f: { type: "object" },
|
|
331
331
|
g: { type: "reference" },
|
|
332
|
-
h: { type: "date" },
|
|
333
332
|
},
|
|
334
333
|
}),
|
|
335
334
|
});
|