@wei840222/qmd 2026.8.23
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/CHANGELOG.md +1373 -0
- package/LICENSE +45 -0
- package/README.md +1439 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/qmd +192 -0
- package/dist/ast.d.ts +65 -0
- package/dist/ast.js +334 -0
- package/dist/bench/bench.d.ts +35 -0
- package/dist/bench/bench.js +338 -0
- package/dist/bench/cjk-baseline.d.ts +36 -0
- package/dist/bench/cjk-baseline.js +111 -0
- package/dist/bench/fixture.d.ts +2 -0
- package/dist/bench/fixture.js +84 -0
- package/dist/bench/score.d.ts +38 -0
- package/dist/bench/score.js +107 -0
- package/dist/bench/types.d.ts +110 -0
- package/dist/bench/types.js +8 -0
- package/dist/cli/build-info.json +4 -0
- package/dist/cli/embed-lock.d.ts +24 -0
- package/dist/cli/embed-lock.js +94 -0
- package/dist/cli/embedding-owner.d.ts +10 -0
- package/dist/cli/embedding-owner.js +20 -0
- package/dist/cli/formatter.d.ts +120 -0
- package/dist/cli/formatter.js +355 -0
- package/dist/cli/mcp-pid.d.ts +25 -0
- package/dist/cli/mcp-pid.js +86 -0
- package/dist/cli/qmd.d.ts +72 -0
- package/dist/cli/qmd.js +4806 -0
- package/dist/cli/version.d.ts +42 -0
- package/dist/cli/version.js +80 -0
- package/dist/collections.d.ts +200 -0
- package/dist/collections.js +433 -0
- package/dist/db.d.ts +65 -0
- package/dist/db.js +143 -0
- package/dist/diagnostics.d.ts +62 -0
- package/dist/diagnostics.js +260 -0
- package/dist/embedding/config.d.ts +52 -0
- package/dist/embedding/config.js +229 -0
- package/dist/embedding/identity.d.ts +58 -0
- package/dist/embedding/identity.js +321 -0
- package/dist/embedding/local-identity.d.ts +1 -0
- package/dist/embedding/local-identity.js +15 -0
- package/dist/embedding/local.d.ts +34 -0
- package/dist/embedding/local.js +290 -0
- package/dist/embedding/openai.d.ts +79 -0
- package/dist/embedding/openai.js +477 -0
- package/dist/embedding/owner.d.ts +13 -0
- package/dist/embedding/owner.js +36 -0
- package/dist/embedding/provider.d.ts +68 -0
- package/dist/embedding/provider.js +16 -0
- package/dist/embedding/remote-chunking.d.ts +22 -0
- package/dist/embedding/remote-chunking.js +83 -0
- package/dist/embedding/remote-embedding.d.ts +15 -0
- package/dist/embedding/remote-embedding.js +77 -0
- package/dist/hybrid-llm.d.ts +18 -0
- package/dist/hybrid-llm.js +53 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +418 -0
- package/dist/llm.d.ts +566 -0
- package/dist/llm.js +1847 -0
- package/dist/maintenance.d.ts +33 -0
- package/dist/maintenance.js +52 -0
- package/dist/mcp/origin-guard.d.ts +67 -0
- package/dist/mcp/origin-guard.js +137 -0
- package/dist/mcp/server.d.ts +116 -0
- package/dist/mcp/server.js +919 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +4 -0
- package/dist/remote-llm.d.ts +52 -0
- package/dist/remote-llm.js +464 -0
- package/dist/search/cjk-analyzer.d.ts +33 -0
- package/dist/search/cjk-analyzer.js +158 -0
- package/dist/search/cjk-index.d.ts +104 -0
- package/dist/search/cjk-index.js +1031 -0
- package/dist/search/jieba-loader.d.ts +23 -0
- package/dist/search/jieba-loader.js +79 -0
- package/dist/search/query-expansion.d.ts +23 -0
- package/dist/search/query-expansion.js +43 -0
- package/dist/search/zh-dict.txt +624013 -0
- package/dist/store.d.ts +1218 -0
- package/dist/store.js +6076 -0
- package/dist/trust.d.ts +152 -0
- package/dist/trust.js +249 -0
- package/package.json +139 -0
- package/scripts/build.mjs +83 -0
- package/scripts/check-package-grammars.mjs +29 -0
- package/scripts/package-smoke.mjs +205 -0
- package/scripts/sync-zh-dict.mjs +187 -0
- package/scripts/test-all.mjs +45 -0
- package/skills/qmd/SKILL.md +324 -0
- package/skills/qmd/references/mcp-setup.md +119 -0
- package/skills/release/SKILL.md +141 -0
- package/skills/release/scripts/install-hooks.sh +38 -0
- package/skills/release/scripts/release-context.sh +129 -0
package/dist/trust.d.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trust.ts — approval gate for project-local `.qmd` config.
|
|
3
|
+
*
|
|
4
|
+
* A project-local `.qmd/index.yml` arrives with a `git clone`, and
|
|
5
|
+
* `findLocalConfigPath` adopts it automatically for any command run inside the
|
|
6
|
+
* tree. Three fields in that file can reach outside the project:
|
|
7
|
+
*
|
|
8
|
+
* - `update:` — a shell command run by `qmd update` (#886)
|
|
9
|
+
* - `collections.*.path` — any directory the process can read (#889)
|
|
10
|
+
* - `models.embed` / `models.rerank` / `models.generate` — any `hf:` repo or
|
|
11
|
+
* local GGUF path (#889)
|
|
12
|
+
*
|
|
13
|
+
* Global `~/.config/qmd` is never gated. In-project collection paths and the
|
|
14
|
+
* built-in default model URIs are also allowed without approval: those are
|
|
15
|
+
* what a local config is for. Approvals are per config file and per gated
|
|
16
|
+
* set, recorded in `<config dir>/trusted.json`. Editing a hook, pointing a
|
|
17
|
+
* collection outside the project, or changing a custom model URI changes the
|
|
18
|
+
* digest and re-arms the gate.
|
|
19
|
+
*/
|
|
20
|
+
/** A collection's pre-update hook, as it will be executed. */
|
|
21
|
+
export type UpdateHook = {
|
|
22
|
+
collection: string;
|
|
23
|
+
command: string;
|
|
24
|
+
};
|
|
25
|
+
export type CollectionPath = {
|
|
26
|
+
collection: string;
|
|
27
|
+
path: string;
|
|
28
|
+
};
|
|
29
|
+
export type ModelSlot = "embed" | "rerank" | "generate";
|
|
30
|
+
export type ModelsSnapshot = {
|
|
31
|
+
embed?: string;
|
|
32
|
+
rerank?: string;
|
|
33
|
+
generate?: string;
|
|
34
|
+
};
|
|
35
|
+
export type SensitiveSnapshot = {
|
|
36
|
+
hooks: UpdateHook[];
|
|
37
|
+
paths: CollectionPath[];
|
|
38
|
+
models: ModelsSnapshot;
|
|
39
|
+
};
|
|
40
|
+
export type GatedItems = {
|
|
41
|
+
hooks: UpdateHook[];
|
|
42
|
+
paths: CollectionPath[];
|
|
43
|
+
models: Array<{
|
|
44
|
+
slot: ModelSlot;
|
|
45
|
+
uri: string;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
export type BuiltinModels = Required<ModelsSnapshot>;
|
|
49
|
+
export type TrustRecord = {
|
|
50
|
+
/** Digest of the gated set that was approved. */
|
|
51
|
+
hooks: string;
|
|
52
|
+
/** ISO timestamp of the approval. */
|
|
53
|
+
trustedAt: string;
|
|
54
|
+
};
|
|
55
|
+
export type TrustStore = Record<string, TrustRecord>;
|
|
56
|
+
/** Path of the trust database. Lives beside the global config. */
|
|
57
|
+
export declare function getTrustFilePath(): string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a config path is project-local — i.e. discovered by walking up from
|
|
60
|
+
* the working directory rather than written by the user in their config dir.
|
|
61
|
+
*
|
|
62
|
+
* `findLocalConfigPath` and `qmd init` both use `.qmd/index.y{a,}ml`, and the
|
|
63
|
+
* global config lives in a directory named `qmd`, so the parent directory name
|
|
64
|
+
* separates the two cases without extra bookkeeping.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isLocalConfigPath(configPath: string): boolean;
|
|
67
|
+
/** Directory that contains the `.qmd` folder for a project-local config. */
|
|
68
|
+
export declare function projectRootFromConfig(configPath: string): string;
|
|
69
|
+
/** Expand a leading `~` to the user home directory. */
|
|
70
|
+
export declare function expandUserPath(raw: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a collection `path` from a project-local config: `~` expansion,
|
|
73
|
+
* then relative paths against the project root (the directory that contains
|
|
74
|
+
* `.qmd`), not against the process cwd.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveConfigCollectionPath(rawPath: string, configPath: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* True if `rawPath` from a project-local config stays inside that project
|
|
79
|
+
* (the directory containing `.qmd`), after `~` expansion, relative
|
|
80
|
+
* resolution, and symlink realpath.
|
|
81
|
+
*/
|
|
82
|
+
export declare function isCollectionPathInsideProject(configPath: string, rawPath: string): boolean;
|
|
83
|
+
export declare function gatedModels(models: ModelsSnapshot, builtins: BuiltinModels): Array<{
|
|
84
|
+
slot: ModelSlot;
|
|
85
|
+
uri: string;
|
|
86
|
+
}>;
|
|
87
|
+
export declare function gatedItems(configPath: string, snapshot: SensitiveSnapshot, builtins: BuiltinModels): GatedItems;
|
|
88
|
+
export declare function hasGatedItems(gated: GatedItems): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Stable digest over a hook set. Order-independent so that reordering
|
|
91
|
+
* collections in the YAML does not invalidate an approval, while any change to
|
|
92
|
+
* a command — or a new collection gaining one — does.
|
|
93
|
+
*/
|
|
94
|
+
export declare function hookDigest(hooks: UpdateHook[]): string;
|
|
95
|
+
/**
|
|
96
|
+
* Digest of the gated surface of a project-local config: hooks, resolved
|
|
97
|
+
* collection paths, and non-default model URIs. Missing model keys and the
|
|
98
|
+
* built-in default URIs are equivalent so that `qmd init` filling defaults
|
|
99
|
+
* into the YAML does not invalidate an approval.
|
|
100
|
+
*/
|
|
101
|
+
export declare function sensitiveDigest(snapshot: SensitiveSnapshot, configPath: string, builtins: BuiltinModels): string;
|
|
102
|
+
export declare function loadTrustStore(): TrustStore;
|
|
103
|
+
export declare function isTrusted(configPath: string, digest: string): boolean;
|
|
104
|
+
export declare function recordTrust(configPath: string, digest: string): void;
|
|
105
|
+
/** Drop the record for a config path. Returns false if there was none. */
|
|
106
|
+
export declare function revokeTrust(configPath: string): boolean;
|
|
107
|
+
export declare function listTrusted(): Array<{
|
|
108
|
+
path: string;
|
|
109
|
+
} & TrustRecord>;
|
|
110
|
+
export type HookGateDecision = {
|
|
111
|
+
action: "run";
|
|
112
|
+
digest: string;
|
|
113
|
+
} | {
|
|
114
|
+
action: "prompt";
|
|
115
|
+
digest: string;
|
|
116
|
+
} | {
|
|
117
|
+
action: "skip";
|
|
118
|
+
digest: string;
|
|
119
|
+
};
|
|
120
|
+
export type LocalConfigGateDecision = HookGateDecision;
|
|
121
|
+
/** True when the process has opted in to trusting project-local config unattended. */
|
|
122
|
+
export declare function isLocalConfigTrustOptedIn(env?: NodeJS.ProcessEnv): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Decide what to do with a config's `update:` hooks.
|
|
125
|
+
*
|
|
126
|
+
* Non-interactive callers — agents, CI, the MCP server — get `skip` rather than
|
|
127
|
+
* a hard failure: indexing is what they asked for, and failing the whole
|
|
128
|
+
* command would only push people toward a blanket opt-out.
|
|
129
|
+
*/
|
|
130
|
+
export declare function decideHookGate(options: {
|
|
131
|
+
configPath: string;
|
|
132
|
+
hooks: UpdateHook[];
|
|
133
|
+
isInteractive: boolean;
|
|
134
|
+
env?: NodeJS.ProcessEnv;
|
|
135
|
+
trustedCheck?: (configPath: string, digest: string) => boolean;
|
|
136
|
+
}): HookGateDecision;
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether a project-local config may use its gated fields (hooks,
|
|
139
|
+
* out-of-project collection paths, non-default model URIs).
|
|
140
|
+
*
|
|
141
|
+
* In-project paths and built-in default models do not need a decision.
|
|
142
|
+
* Non-interactive callers get `skip`: in-project indexing still proceeds,
|
|
143
|
+
* hooks/outside paths/custom models do not.
|
|
144
|
+
*/
|
|
145
|
+
export declare function decideLocalConfigGate(options: {
|
|
146
|
+
configPath: string;
|
|
147
|
+
snapshot: SensitiveSnapshot;
|
|
148
|
+
builtins: BuiltinModels;
|
|
149
|
+
isInteractive: boolean;
|
|
150
|
+
env?: NodeJS.ProcessEnv;
|
|
151
|
+
trustedCheck?: (configPath: string, digest: string) => boolean;
|
|
152
|
+
}): LocalConfigGateDecision;
|
package/dist/trust.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trust.ts — approval gate for project-local `.qmd` config.
|
|
3
|
+
*
|
|
4
|
+
* A project-local `.qmd/index.yml` arrives with a `git clone`, and
|
|
5
|
+
* `findLocalConfigPath` adopts it automatically for any command run inside the
|
|
6
|
+
* tree. Three fields in that file can reach outside the project:
|
|
7
|
+
*
|
|
8
|
+
* - `update:` — a shell command run by `qmd update` (#886)
|
|
9
|
+
* - `collections.*.path` — any directory the process can read (#889)
|
|
10
|
+
* - `models.embed` / `models.rerank` / `models.generate` — any `hf:` repo or
|
|
11
|
+
* local GGUF path (#889)
|
|
12
|
+
*
|
|
13
|
+
* Global `~/.config/qmd` is never gated. In-project collection paths and the
|
|
14
|
+
* built-in default model URIs are also allowed without approval: those are
|
|
15
|
+
* what a local config is for. Approvals are per config file and per gated
|
|
16
|
+
* set, recorded in `<config dir>/trusted.json`. Editing a hook, pointing a
|
|
17
|
+
* collection outside the project, or changing a custom model URI changes the
|
|
18
|
+
* digest and re-arms the gate.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "crypto";
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
|
|
23
|
+
import { getConfigDir } from "./collections.js";
|
|
24
|
+
import { qmdHomedir } from "./paths.js";
|
|
25
|
+
/** Path of the trust database. Lives beside the global config. */
|
|
26
|
+
export function getTrustFilePath() {
|
|
27
|
+
return join(getConfigDir(), "trusted.json");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether a config path is project-local — i.e. discovered by walking up from
|
|
31
|
+
* the working directory rather than written by the user in their config dir.
|
|
32
|
+
*
|
|
33
|
+
* `findLocalConfigPath` and `qmd init` both use `.qmd/index.y{a,}ml`, and the
|
|
34
|
+
* global config lives in a directory named `qmd`, so the parent directory name
|
|
35
|
+
* separates the two cases without extra bookkeeping.
|
|
36
|
+
*/
|
|
37
|
+
export function isLocalConfigPath(configPath) {
|
|
38
|
+
if (!configPath || configPath === "<inline>")
|
|
39
|
+
return false;
|
|
40
|
+
return basename(dirname(resolve(configPath))) === ".qmd";
|
|
41
|
+
}
|
|
42
|
+
/** Directory that contains the `.qmd` folder for a project-local config. */
|
|
43
|
+
export function projectRootFromConfig(configPath) {
|
|
44
|
+
return dirname(dirname(resolve(configPath)));
|
|
45
|
+
}
|
|
46
|
+
/** Expand a leading `~` to the user home directory. */
|
|
47
|
+
export function expandUserPath(raw) {
|
|
48
|
+
if (raw === "~")
|
|
49
|
+
return qmdHomedir();
|
|
50
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
51
|
+
return join(qmdHomedir(), raw.slice(2));
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a collection `path` from a project-local config: `~` expansion,
|
|
57
|
+
* then relative paths against the project root (the directory that contains
|
|
58
|
+
* `.qmd`), not against the process cwd.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveConfigCollectionPath(rawPath, configPath) {
|
|
61
|
+
const expanded = expandUserPath(String(rawPath ?? "").trim());
|
|
62
|
+
if (!expanded)
|
|
63
|
+
return projectRootFromConfig(configPath);
|
|
64
|
+
if (isAbsolute(expanded))
|
|
65
|
+
return resolve(expanded);
|
|
66
|
+
return resolve(projectRootFromConfig(configPath), expanded);
|
|
67
|
+
}
|
|
68
|
+
function realOrResolve(path) {
|
|
69
|
+
try {
|
|
70
|
+
return realpathSync(path);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return resolve(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True if `rawPath` from a project-local config stays inside that project
|
|
78
|
+
* (the directory containing `.qmd`), after `~` expansion, relative
|
|
79
|
+
* resolution, and symlink realpath.
|
|
80
|
+
*/
|
|
81
|
+
export function isCollectionPathInsideProject(configPath, rawPath) {
|
|
82
|
+
const root = realOrResolve(projectRootFromConfig(configPath));
|
|
83
|
+
const target = realOrResolve(resolveConfigCollectionPath(rawPath, configPath));
|
|
84
|
+
const rel = relative(root, target);
|
|
85
|
+
if (rel === "")
|
|
86
|
+
return true;
|
|
87
|
+
if (isAbsolute(rel))
|
|
88
|
+
return false;
|
|
89
|
+
return !rel.split(/[/\\]/).includes("..");
|
|
90
|
+
}
|
|
91
|
+
export function gatedModels(models, builtins) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const slot of ["embed", "rerank", "generate"]) {
|
|
94
|
+
const uri = models[slot];
|
|
95
|
+
if (!uri)
|
|
96
|
+
continue;
|
|
97
|
+
if (uri === builtins[slot])
|
|
98
|
+
continue;
|
|
99
|
+
out.push({ slot, uri });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
export function gatedItems(configPath, snapshot, builtins) {
|
|
104
|
+
return {
|
|
105
|
+
hooks: snapshot.hooks,
|
|
106
|
+
paths: snapshot.paths.filter(p => !isCollectionPathInsideProject(configPath, p.path)),
|
|
107
|
+
models: gatedModels(snapshot.models, builtins),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function hasGatedItems(gated) {
|
|
111
|
+
return gated.hooks.length > 0 || gated.paths.length > 0 || gated.models.length > 0;
|
|
112
|
+
}
|
|
113
|
+
function byFirst(a, b) {
|
|
114
|
+
const left = a[0] ?? "";
|
|
115
|
+
const right = b[0] ?? "";
|
|
116
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stable digest over a hook set. Order-independent so that reordering
|
|
120
|
+
* collections in the YAML does not invalidate an approval, while any change to
|
|
121
|
+
* a command — or a new collection gaining one — does.
|
|
122
|
+
*/
|
|
123
|
+
export function hookDigest(hooks) {
|
|
124
|
+
const canonical = JSON.stringify(hooks
|
|
125
|
+
.map(h => [h.collection, h.command])
|
|
126
|
+
.sort(byFirst));
|
|
127
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Digest of the gated surface of a project-local config: hooks, resolved
|
|
131
|
+
* collection paths, and non-default model URIs. Missing model keys and the
|
|
132
|
+
* built-in default URIs are equivalent so that `qmd init` filling defaults
|
|
133
|
+
* into the YAML does not invalidate an approval.
|
|
134
|
+
*/
|
|
135
|
+
export function sensitiveDigest(snapshot, configPath, builtins) {
|
|
136
|
+
const gated = gatedItems(configPath, snapshot, builtins);
|
|
137
|
+
const canonical = JSON.stringify({
|
|
138
|
+
hooks: gated.hooks.map(h => [h.collection, h.command]).sort(byFirst),
|
|
139
|
+
paths: gated.paths
|
|
140
|
+
.map(p => [p.collection, resolveConfigCollectionPath(p.path, configPath)])
|
|
141
|
+
.sort(byFirst),
|
|
142
|
+
models: gated.models
|
|
143
|
+
.map(m => [m.slot, m.uri])
|
|
144
|
+
.sort(byFirst),
|
|
145
|
+
});
|
|
146
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
147
|
+
}
|
|
148
|
+
export function loadTrustStore() {
|
|
149
|
+
const path = getTrustFilePath();
|
|
150
|
+
if (!existsSync(path))
|
|
151
|
+
return {};
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
154
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
155
|
+
return {};
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// A corrupt trust file must not be treated as "everything is trusted".
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function saveTrustStore(store) {
|
|
164
|
+
const path = getTrustFilePath();
|
|
165
|
+
const dir = dirname(path);
|
|
166
|
+
if (!existsSync(dir))
|
|
167
|
+
mkdirSync(dir, { recursive: true });
|
|
168
|
+
writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, "utf-8");
|
|
169
|
+
}
|
|
170
|
+
/** Trust records are keyed on the resolved config path. */
|
|
171
|
+
function trustKey(configPath) {
|
|
172
|
+
return resolve(configPath);
|
|
173
|
+
}
|
|
174
|
+
export function isTrusted(configPath, digest) {
|
|
175
|
+
const record = loadTrustStore()[trustKey(configPath)];
|
|
176
|
+
return record?.hooks === digest;
|
|
177
|
+
}
|
|
178
|
+
export function recordTrust(configPath, digest) {
|
|
179
|
+
const store = loadTrustStore();
|
|
180
|
+
store[trustKey(configPath)] = { hooks: digest, trustedAt: new Date().toISOString() };
|
|
181
|
+
saveTrustStore(store);
|
|
182
|
+
}
|
|
183
|
+
/** Drop the record for a config path. Returns false if there was none. */
|
|
184
|
+
export function revokeTrust(configPath) {
|
|
185
|
+
const store = loadTrustStore();
|
|
186
|
+
const key = trustKey(configPath);
|
|
187
|
+
if (!(key in store))
|
|
188
|
+
return false;
|
|
189
|
+
delete store[key];
|
|
190
|
+
saveTrustStore(store);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
export function listTrusted() {
|
|
194
|
+
return Object.entries(loadTrustStore()).map(([path, record]) => ({ path, ...record }));
|
|
195
|
+
}
|
|
196
|
+
function isTruthyEnv(value) {
|
|
197
|
+
if (!value)
|
|
198
|
+
return false;
|
|
199
|
+
return !["0", "false", "off", "no", "none"].includes(value.trim().toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
/** True when the process has opted in to trusting project-local config unattended. */
|
|
202
|
+
export function isLocalConfigTrustOptedIn(env = process.env) {
|
|
203
|
+
return isTruthyEnv(env.QMD_TRUST_LOCAL_CONFIG) || isTruthyEnv(env.QMD_TRUST_UPDATE_HOOKS);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Decide what to do with a config's `update:` hooks.
|
|
207
|
+
*
|
|
208
|
+
* Non-interactive callers — agents, CI, the MCP server — get `skip` rather than
|
|
209
|
+
* a hard failure: indexing is what they asked for, and failing the whole
|
|
210
|
+
* command would only push people toward a blanket opt-out.
|
|
211
|
+
*/
|
|
212
|
+
export function decideHookGate(options) {
|
|
213
|
+
const env = options.env ?? process.env;
|
|
214
|
+
const digest = hookDigest(options.hooks);
|
|
215
|
+
if (options.hooks.length === 0)
|
|
216
|
+
return { action: "run", digest };
|
|
217
|
+
if (isTruthyEnv(env.QMD_TRUST_UPDATE_HOOKS) || isTruthyEnv(env.QMD_TRUST_LOCAL_CONFIG)) {
|
|
218
|
+
return { action: "run", digest };
|
|
219
|
+
}
|
|
220
|
+
if (!isLocalConfigPath(options.configPath))
|
|
221
|
+
return { action: "run", digest };
|
|
222
|
+
const trusted = options.trustedCheck ?? isTrusted;
|
|
223
|
+
if (trusted(options.configPath, digest))
|
|
224
|
+
return { action: "run", digest };
|
|
225
|
+
return { action: options.isInteractive ? "prompt" : "skip", digest };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Decide whether a project-local config may use its gated fields (hooks,
|
|
229
|
+
* out-of-project collection paths, non-default model URIs).
|
|
230
|
+
*
|
|
231
|
+
* In-project paths and built-in default models do not need a decision.
|
|
232
|
+
* Non-interactive callers get `skip`: in-project indexing still proceeds,
|
|
233
|
+
* hooks/outside paths/custom models do not.
|
|
234
|
+
*/
|
|
235
|
+
export function decideLocalConfigGate(options) {
|
|
236
|
+
const env = options.env ?? process.env;
|
|
237
|
+
const digest = sensitiveDigest(options.snapshot, options.configPath, options.builtins);
|
|
238
|
+
const gated = gatedItems(options.configPath, options.snapshot, options.builtins);
|
|
239
|
+
if (!hasGatedItems(gated))
|
|
240
|
+
return { action: "run", digest };
|
|
241
|
+
if (isLocalConfigTrustOptedIn(env))
|
|
242
|
+
return { action: "run", digest };
|
|
243
|
+
if (!isLocalConfigPath(options.configPath))
|
|
244
|
+
return { action: "run", digest };
|
|
245
|
+
const trusted = options.trustedCheck ?? isTrusted;
|
|
246
|
+
if (trusted(options.configPath, digest))
|
|
247
|
+
return { action: "run", digest };
|
|
248
|
+
return { action: options.isInteractive ? "prompt" : "skip", digest };
|
|
249
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wei840222/qmd",
|
|
3
|
+
"version": "2026.8.23",
|
|
4
|
+
"packageManager": "pnpm@11.15.1",
|
|
5
|
+
"description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"qmd": "bin/qmd"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/",
|
|
20
|
+
"dist/",
|
|
21
|
+
"skills/",
|
|
22
|
+
"scripts/build.mjs",
|
|
23
|
+
"scripts/check-package-grammars.mjs",
|
|
24
|
+
"scripts/package-smoke.mjs",
|
|
25
|
+
"scripts/sync-zh-dict.mjs",
|
|
26
|
+
"scripts/test-all.mjs",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"THIRD_PARTY_NOTICES.md",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"prepare": "node scripts/install-hooks.mjs && node scripts/build.mjs",
|
|
33
|
+
"build": "node scripts/build.mjs",
|
|
34
|
+
"lint": "oxlint",
|
|
35
|
+
"test": "node scripts/test-all.mjs",
|
|
36
|
+
"test:types": "node ./node_modules/typescript/bin/tsc -p tsconfig.build.json --noEmit",
|
|
37
|
+
"test:node": "bun scripts/test-runtime.mjs node",
|
|
38
|
+
"test:bun": "bun scripts/test-runtime.mjs bun",
|
|
39
|
+
"test:models:node": "bun scripts/test-runtime.mjs node --models-only",
|
|
40
|
+
"test:models:bun": "bun scripts/test-runtime.mjs bun --models-only",
|
|
41
|
+
"test:unit": "CI=true node ./node_modules/vitest/vitest.mjs run --reporter=verbose --testTimeout 60000 test/ && CI=true bun test --timeout 60000 --preload ./src/test-preload.ts test/",
|
|
42
|
+
"test:quality": "node ./node_modules/vitest/vitest.mjs run --reporter=verbose test/eval-cjk.test.ts",
|
|
43
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
44
|
+
"smoke:package-grammars": "node scripts/check-package-grammars.mjs",
|
|
45
|
+
"dict:sync": "node scripts/sync-zh-dict.mjs",
|
|
46
|
+
"measure:cjk": "bun scripts/measure-cjk.ts",
|
|
47
|
+
"inspector": "bunx @modelcontextprotocol/inspector tsx src/cli/qmd.ts mcp",
|
|
48
|
+
"release": "./scripts/release.sh",
|
|
49
|
+
"qmd": "tsx src/cli/qmd.ts",
|
|
50
|
+
"index": "tsx src/cli/qmd.ts index",
|
|
51
|
+
"vector": "tsx src/cli/qmd.ts vector",
|
|
52
|
+
"search": "tsx src/cli/qmd.ts search",
|
|
53
|
+
"vsearch": "tsx src/cli/qmd.ts vsearch",
|
|
54
|
+
"rerank": "tsx src/cli/qmd.ts rerank",
|
|
55
|
+
"bench": "bun src/cli/qmd.ts bench"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/wei840222/qmd.git"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/wei840222/qmd#readme",
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/wei840222/qmd/issues"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
70
|
+
"@node-rs/jieba": "2.0.1",
|
|
71
|
+
"better-sqlite3": "^13.0.3",
|
|
72
|
+
"fast-glob": "3.3.3",
|
|
73
|
+
"node-llama-cpp": "3.20.0",
|
|
74
|
+
"picomatch": "4.0.5",
|
|
75
|
+
"sqlite-vec": "0.1.9",
|
|
76
|
+
"tree-sitter-go": "0.25.0",
|
|
77
|
+
"tree-sitter-python": "0.25.0",
|
|
78
|
+
"tree-sitter-rust": "0.24.0",
|
|
79
|
+
"tree-sitter-typescript": "0.23.2",
|
|
80
|
+
"web-tree-sitter": "0.26.12",
|
|
81
|
+
"yaml": "2.9.0",
|
|
82
|
+
"zod": "4.2.1"
|
|
83
|
+
},
|
|
84
|
+
"optionalDependencies": {
|
|
85
|
+
"sqlite-vec-darwin-arm64": "0.1.9",
|
|
86
|
+
"sqlite-vec-darwin-x64": "0.1.9",
|
|
87
|
+
"sqlite-vec-linux-arm64": "0.1.9",
|
|
88
|
+
"sqlite-vec-linux-x64": "0.1.9",
|
|
89
|
+
"sqlite-vec-windows-x64": "0.1.9"
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"@oxlint/plugins": "1.78.0",
|
|
93
|
+
"@types/better-sqlite3": "7.6.13",
|
|
94
|
+
"oxlint": "1.78.0",
|
|
95
|
+
"tsx": "4.23.12",
|
|
96
|
+
"vitest": "3.2.7"
|
|
97
|
+
},
|
|
98
|
+
"overrides": {
|
|
99
|
+
"@hono/node-server": "2.0.10",
|
|
100
|
+
"ajv": "8.18.0",
|
|
101
|
+
"esbuild": "0.28.1",
|
|
102
|
+
"fast-uri": "3.1.5",
|
|
103
|
+
"hono": "4.12.34",
|
|
104
|
+
"ip-address": "10.3.1",
|
|
105
|
+
"nanoid": "3.3.18",
|
|
106
|
+
"path-to-regexp": "8.4.0",
|
|
107
|
+
"postcss": "8.5.23",
|
|
108
|
+
"qs": "6.15.3",
|
|
109
|
+
"rollup": "4.59.0",
|
|
110
|
+
"simple-git": "3.36.0",
|
|
111
|
+
"tar": "7.5.21",
|
|
112
|
+
"vite": "7.3.5"
|
|
113
|
+
},
|
|
114
|
+
"peerDependencies": {
|
|
115
|
+
"typescript": "^5.9.3"
|
|
116
|
+
},
|
|
117
|
+
"engines": {
|
|
118
|
+
"node": ">=22.0.0"
|
|
119
|
+
},
|
|
120
|
+
"keywords": [
|
|
121
|
+
"markdown",
|
|
122
|
+
"search",
|
|
123
|
+
"fts",
|
|
124
|
+
"full-text-search",
|
|
125
|
+
"vector",
|
|
126
|
+
"semantic-search",
|
|
127
|
+
"sqlite",
|
|
128
|
+
"bm25",
|
|
129
|
+
"embeddings",
|
|
130
|
+
"rag",
|
|
131
|
+
"mcp",
|
|
132
|
+
"reranking",
|
|
133
|
+
"knowledge-base",
|
|
134
|
+
"local-ai",
|
|
135
|
+
"llm"
|
|
136
|
+
],
|
|
137
|
+
"author": "Tobi Lutke <tobi@lutke.com>",
|
|
138
|
+
"license": "MIT"
|
|
139
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, copyFileSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
8
|
+
|
|
9
|
+
function run(command, args, options = {}) {
|
|
10
|
+
const result = spawnSync(command, args, {
|
|
11
|
+
cwd: root,
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
// Never shell:true — on Windows, cmd.exe splits unquoted process.execPath
|
|
14
|
+
// at spaces (C:\Program Files\nodejs\node.exe) and the build can exit 0
|
|
15
|
+
// with no dist/. spawnSync can spawn the binary + args array directly. (#681)
|
|
16
|
+
shell: false,
|
|
17
|
+
...options,
|
|
18
|
+
});
|
|
19
|
+
if (result.error || result.status !== 0) {
|
|
20
|
+
if (result.error) {
|
|
21
|
+
console.error(`build: failed to spawn ${command}: ${result.error.message}`);
|
|
22
|
+
}
|
|
23
|
+
process.exit(result.status ?? 1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
run(process.execPath, [join(root, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.build.json"]);
|
|
28
|
+
copyFileSync(
|
|
29
|
+
join(root, "src", "search", "zh-dict.txt"),
|
|
30
|
+
join(root, "dist", "search", "zh-dict.txt"),
|
|
31
|
+
);
|
|
32
|
+
for (const path of [
|
|
33
|
+
"zh-tw-dictionary.txt",
|
|
34
|
+
"zh-tw-tech-dictionary.js",
|
|
35
|
+
"zh-tw-tech-dictionary.d.ts",
|
|
36
|
+
]) {
|
|
37
|
+
rmSync(join(root, "dist", "search", path), { force: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cliPath = join(root, "dist", "cli", "qmd.js");
|
|
41
|
+
const tmpPath = `${cliPath}.tmp`;
|
|
42
|
+
const built = readFileSync(cliPath, "utf8");
|
|
43
|
+
const withoutExistingShebang = built.startsWith("#!") ? built.slice(built.indexOf("\n") + 1) : built;
|
|
44
|
+
writeFileSync(tmpPath, `#!/usr/bin/env node\n${withoutExistingShebang}`);
|
|
45
|
+
renameSync(tmpPath, cliPath);
|
|
46
|
+
chmodSync(cliPath, 0o755);
|
|
47
|
+
|
|
48
|
+
// Stamp the commit this build came from, for `qmd --version`.
|
|
49
|
+
//
|
|
50
|
+
// It has to happen here: a published tarball has no git history of its own, so
|
|
51
|
+
// discovering the commit at runtime finds whatever repository the install
|
|
52
|
+
// happens to sit inside (a global install under /opt/homebrew reported
|
|
53
|
+
// Homebrew's HEAD as qmd's). See src/cli/version.ts.
|
|
54
|
+
//
|
|
55
|
+
// Uses spawnSync directly rather than run() above, because run() exits the
|
|
56
|
+
// build on a non-zero status: no git, no repo, or a source tarball must all
|
|
57
|
+
// degrade to an unstamped build, not a failed one.
|
|
58
|
+
function git(args) {
|
|
59
|
+
const result = spawnSync("git", args, { cwd: root, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
60
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
61
|
+
return result.stdout.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildCommit() {
|
|
65
|
+
const top = git(["rev-parse", "--show-toplevel"]);
|
|
66
|
+
// Only trust a repository that *is* this package — same rule the runtime
|
|
67
|
+
// fallback applies, so building from inside an unrelated checkout can't
|
|
68
|
+
// stamp that checkout's commit.
|
|
69
|
+
if (top === null || realpathSync(top) !== realpathSync(root)) return "";
|
|
70
|
+
|
|
71
|
+
const commit = git(["rev-parse", "--short", "HEAD"]);
|
|
72
|
+
if (!commit) return "";
|
|
73
|
+
|
|
74
|
+
// A build from a dirty tree is not the commit it claims to be; say so, so
|
|
75
|
+
// "did my install actually take?" has an answer.
|
|
76
|
+
const status = git(["status", "--porcelain"]);
|
|
77
|
+
return status ? `${commit}-dirty` : commit;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(root, "dist", "cli", "build-info.json"),
|
|
82
|
+
`${JSON.stringify({ commit: buildCommit(), builtAt: new Date().toISOString() }, null, 2)}\n`,
|
|
83
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
const grammars = [
|
|
7
|
+
"tree-sitter-typescript/tree-sitter-typescript.wasm",
|
|
8
|
+
"tree-sitter-typescript/tree-sitter-tsx.wasm",
|
|
9
|
+
"tree-sitter-python/tree-sitter-python.wasm",
|
|
10
|
+
"tree-sitter-go/tree-sitter-go.wasm",
|
|
11
|
+
"tree-sitter-rust/tree-sitter-rust.wasm",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
let ok = true;
|
|
15
|
+
for (const grammar of grammars) {
|
|
16
|
+
try {
|
|
17
|
+
const resolved = require.resolve(grammar);
|
|
18
|
+
console.log(`ok ${grammar} -> ${resolved}`);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
ok = false;
|
|
21
|
+
console.error(`missing ${grammar}`);
|
|
22
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!ok) {
|
|
27
|
+
console.error("\nAST grammar package smoke check failed. Run `bun install` locally or repair a broken global install with the matching `bun add tree-sitter-...@<version>` command shown by `qmd status`.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|