@gmickel/gno 1.25.1 → 1.27.0
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 +12 -5
- package/assets/skill/SKILL.md +37 -17
- package/browser-extension/artifacts/{gno-browser-clipper-v1.25.1.zip → gno-browser-clipper-v1.27.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +161 -6
- package/spec/db/schema.sql +1 -1
- package/spec/evals-agentic.md +17 -0
- package/spec/mcp.md +25 -6
- package/spec/output-schemas/ask.schema.json +3 -0
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +68 -5
- package/spec/output-schemas/search-results.schema.json +87 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/output-schemas/status.schema.json +24 -0
- package/spec/project-profile.schema.json +303 -0
- package/src/app/context-runtime-contract.ts +4 -1
- package/src/app/context-runtime-types.ts +2 -0
- package/src/app/context-runtime.ts +26 -0
- package/src/app/verified-ask.ts +6 -1
- package/src/cli/commands/ask.ts +8 -1
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/query.ts +6 -3
- package/src/cli/commands/search.ts +6 -1
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/commands/status.ts +43 -7
- package/src/cli/program.ts +112 -9
- package/src/config/content-types.ts +82 -0
- package/src/config/index.ts +11 -0
- package/src/config/project-profile.ts +374 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +53 -2
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-compiler.ts +38 -1
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/core/retrieval-replay-candidate.ts +6 -1
- package/src/ingestion/sync-options.ts +6 -2
- package/src/ingestion/sync.ts +21 -29
- package/src/ingestion/types.ts +1 -1
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/mcp/tools/ask.ts +1 -0
- package/src/mcp/tools/index.ts +4 -0
- package/src/mcp/tools/query.ts +4 -2
- package/src/mcp/tools/search.ts +3 -0
- package/src/mcp/tools/status.ts +4 -0
- package/src/pipeline/content-type-boost.ts +264 -0
- package/src/pipeline/diagnose.ts +46 -19
- package/src/pipeline/explain.ts +15 -2
- package/src/pipeline/hybrid.ts +170 -74
- package/src/pipeline/rerank.ts +45 -15
- package/src/pipeline/search.ts +29 -11
- package/src/pipeline/types.ts +13 -4
- package/src/pipeline/vsearch.ts +30 -10
- package/src/sdk/client.ts +19 -3
- package/src/sdk/index.ts +1 -0
- package/src/sdk/types.ts +21 -5
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +18 -3
- package/src/serve/status-model.ts +2 -0
- package/src/serve/status.ts +4 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +86 -0
- package/src/store/types.ts +3 -2
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Canonical config mutation helper shared by every config writer.
|
|
3
3
|
*
|
|
4
4
|
* @module src/core/config-mutation
|
|
5
5
|
*/
|
|
@@ -9,27 +9,42 @@ import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
formatConfigWarnings,
|
|
12
|
+
getConfigPaths,
|
|
12
13
|
loadConfig,
|
|
13
14
|
normalizeConfigContentTypes,
|
|
14
15
|
saveConfig,
|
|
15
16
|
} from "../config";
|
|
17
|
+
import { resolveConfigWriteTarget } from "./config-write-lock";
|
|
16
18
|
import { withWriteLock } from "./file-lock";
|
|
17
19
|
|
|
18
|
-
export interface
|
|
19
|
-
store: SqliteAdapter;
|
|
20
|
+
export interface ConfigFileMutationContext {
|
|
20
21
|
configPath?: string;
|
|
21
|
-
onConfigUpdated: (config: Config) => void;
|
|
22
22
|
/**
|
|
23
|
-
* Optional
|
|
24
|
-
*
|
|
25
|
-
|
|
23
|
+
* Optional first-run config factory. Callers must opt in explicitly; existing
|
|
24
|
+
* mutation surfaces keep treating a missing config as an error.
|
|
25
|
+
*/
|
|
26
|
+
createConfigIfMissing?: () => Config;
|
|
27
|
+
onConfigUpdated?: (config: Config) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConfigMutationContext extends ConfigFileMutationContext {
|
|
31
|
+
store: SqliteAdapter;
|
|
32
|
+
/**
|
|
33
|
+
* Optional targeted store projection. The default reconciles the complete
|
|
34
|
+
* config. Create/update-only callers can project a bounded subset without
|
|
35
|
+
* deleting DB-only recovery state.
|
|
26
36
|
*/
|
|
27
|
-
|
|
37
|
+
projectStore?: (
|
|
38
|
+
store: SqliteAdapter,
|
|
39
|
+
config: Config
|
|
40
|
+
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
28
41
|
/**
|
|
29
42
|
* Runs after the selected config is durably present and before store projection.
|
|
30
43
|
* Setup recovery uses this boundary to persist a truthful resumable receipt.
|
|
31
44
|
*/
|
|
32
45
|
afterConfigSaved?: (config: Config) => Promise<void> | void;
|
|
46
|
+
/** Runs after config projection succeeds while the write lock is still held. */
|
|
47
|
+
afterStoreSynced?: (config: Config) => Promise<void> | void;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export type MutationResult<T = void> =
|
|
@@ -40,68 +55,130 @@ export type ApplyConfigResult<T = void> =
|
|
|
40
55
|
| { ok: true; config: Config; value?: T }
|
|
41
56
|
| { ok: false; error: string; code: string };
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
let configMutex: Promise<void> = Promise.resolve();
|
|
58
|
+
export interface ConfigMutationState {
|
|
59
|
+
created: boolean;
|
|
60
|
+
}
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
type PersistedConfigHook = (
|
|
63
|
+
config: Config
|
|
64
|
+
) => Promise<{ ok: true } | { ok: false; error: string; code: string }>;
|
|
65
|
+
|
|
66
|
+
async function applySerializedConfigChange<T>(
|
|
67
|
+
ctx: ConfigFileMutationContext,
|
|
68
|
+
mutate: (
|
|
69
|
+
config: Config,
|
|
70
|
+
state: ConfigMutationState
|
|
71
|
+
) => Promise<MutationResult<T>> | MutationResult<T>,
|
|
72
|
+
afterPersist?: PersistedConfigHook
|
|
52
73
|
): Promise<ApplyConfigResult<T>> {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ok: false,
|
|
70
|
-
error: loadResult.error.message,
|
|
71
|
-
code: "LOAD_ERROR",
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
+
const requestedConfigPath = ctx.configPath ?? getConfigPaths().configFile;
|
|
75
|
+
const writeTarget = await resolveConfigWriteTarget(requestedConfigPath);
|
|
76
|
+
const selectedConfigPath = writeTarget.configPath;
|
|
77
|
+
const applyFreshConfigChange = async (): Promise<ApplyConfigResult<T>> => {
|
|
78
|
+
const loadResult = await loadConfig(selectedConfigPath);
|
|
79
|
+
if (
|
|
80
|
+
!loadResult.ok &&
|
|
81
|
+
!(loadResult.error.code === "NOT_FOUND" && ctx.createConfigIfMissing)
|
|
82
|
+
) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: loadResult.error.message,
|
|
86
|
+
code: "LOAD_ERROR",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (loadResult.ok) {
|
|
74
90
|
for (const warning of formatConfigWarnings(loadResult.warnings)) {
|
|
75
91
|
console.warn(warning);
|
|
76
92
|
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const currentConfig = loadResult.ok
|
|
96
|
+
? loadResult.value
|
|
97
|
+
: ctx.createConfigIfMissing?.();
|
|
98
|
+
if (!currentConfig) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: "Config file is missing",
|
|
102
|
+
code: "LOAD_ERROR",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const mutationResult = await mutate(currentConfig, {
|
|
106
|
+
created: !loadResult.ok,
|
|
107
|
+
});
|
|
108
|
+
if (!mutationResult.ok) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: mutationResult.error,
|
|
112
|
+
code: mutationResult.code,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
77
115
|
|
|
78
|
-
|
|
79
|
-
|
|
116
|
+
const normalized = normalizeConfigContentTypes(mutationResult.config);
|
|
117
|
+
for (const warning of formatConfigWarnings(normalized.warnings)) {
|
|
118
|
+
console.warn(warning);
|
|
119
|
+
}
|
|
120
|
+
const newConfig = normalized.config;
|
|
121
|
+
if (!mutationResult.skipSave) {
|
|
122
|
+
const saveResult = await saveConfig(newConfig, selectedConfigPath);
|
|
123
|
+
if (!saveResult.ok) {
|
|
80
124
|
return {
|
|
81
125
|
ok: false,
|
|
82
|
-
error:
|
|
83
|
-
code:
|
|
126
|
+
error: saveResult.error.message,
|
|
127
|
+
code: "SAVE_ERROR",
|
|
84
128
|
};
|
|
85
129
|
}
|
|
130
|
+
}
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
const persisted = await afterPersist?.(newConfig);
|
|
133
|
+
if (persisted && !persisted.ok) return persisted;
|
|
134
|
+
ctx.onConfigUpdated?.(newConfig);
|
|
135
|
+
|
|
136
|
+
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return await withWriteLock(writeTarget.lockPath, applyFreshConfigChange);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof Error && error.message.startsWith("LOCKED:")) {
|
|
143
|
+
return { ok: false, error: error.message, code: "LOCKED" };
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
102
148
|
|
|
103
|
-
|
|
149
|
+
/** Mutate only the selected config file under the canonical shared lock. */
|
|
150
|
+
export async function applyConfigFileChange<T = void>(
|
|
151
|
+
ctx: ConfigFileMutationContext,
|
|
152
|
+
mutate: (
|
|
153
|
+
config: Config,
|
|
154
|
+
state: ConfigMutationState
|
|
155
|
+
) => Promise<MutationResult<T>> | MutationResult<T>
|
|
156
|
+
): Promise<ApplyConfigResult<T>> {
|
|
157
|
+
return applySerializedConfigChange(ctx, mutate);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Mutate config and project it to the selected store in one locked boundary. */
|
|
161
|
+
export async function applyConfigChange<T = void>(
|
|
162
|
+
ctx: ConfigMutationContext,
|
|
163
|
+
mutate: (
|
|
164
|
+
config: Config,
|
|
165
|
+
state: ConfigMutationState
|
|
166
|
+
) => Promise<MutationResult<T>> | MutationResult<T>
|
|
167
|
+
): Promise<ApplyConfigResult<T>> {
|
|
168
|
+
return applySerializedConfigChange(ctx, mutate, async (newConfig) => {
|
|
169
|
+
await ctx.afterConfigSaved?.(newConfig);
|
|
104
170
|
|
|
171
|
+
if (ctx.projectStore) {
|
|
172
|
+
const projection = await ctx.projectStore(ctx.store, newConfig);
|
|
173
|
+
if (!projection.ok) {
|
|
174
|
+
console.warn(`Config saved but DB sync failed: ${projection.error}`);
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `DB sync failed: ${projection.error}`,
|
|
178
|
+
code: "SYNC_ERROR",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
105
182
|
const syncCollResult = await ctx.store.syncCollections(
|
|
106
183
|
newConfig.collections
|
|
107
184
|
);
|
|
@@ -129,24 +206,9 @@ export async function applyConfigChange<T = void>(
|
|
|
129
206
|
code: "SYNC_ERROR",
|
|
130
207
|
};
|
|
131
208
|
}
|
|
132
|
-
|
|
133
|
-
ctx.onConfigUpdated(newConfig);
|
|
134
|
-
|
|
135
|
-
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
if (!ctx.writeLockPath) {
|
|
139
|
-
return await applyFreshConfigChange();
|
|
140
|
-
}
|
|
141
|
-
try {
|
|
142
|
-
return await withWriteLock(ctx.writeLockPath, applyFreshConfigChange);
|
|
143
|
-
} catch (error) {
|
|
144
|
-
if (error instanceof Error && error.message.startsWith("LOCKED:")) {
|
|
145
|
-
return { ok: false, error: error.message, code: "LOCKED" };
|
|
146
|
-
}
|
|
147
|
-
throw error;
|
|
148
209
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
210
|
+
|
|
211
|
+
await ctx.afterStoreSynced?.(newConfig);
|
|
212
|
+
return { ok: true };
|
|
213
|
+
});
|
|
152
214
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical cross-process lock identity for all config writers.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/config-write-lock
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises provides symlink-aware path operations; Bun has no equivalent for canonical path identity.
|
|
8
|
+
import { lstat, readlink, realpath } from "node:fs/promises";
|
|
9
|
+
// node:path provides structural path operations; Bun has no path utilities.
|
|
10
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { expandPath } from "../config/paths";
|
|
13
|
+
|
|
14
|
+
const MISSING_PATH_ERROR_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
15
|
+
|
|
16
|
+
function isMissingPathError(cause: unknown): boolean {
|
|
17
|
+
return (
|
|
18
|
+
cause instanceof Error &&
|
|
19
|
+
"code" in cause &&
|
|
20
|
+
typeof cause.code === "string" &&
|
|
21
|
+
MISSING_PATH_ERROR_CODES.has(cause.code)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function canonicalizeProspectivePath(
|
|
26
|
+
absolutePath: string,
|
|
27
|
+
seenLinks: Set<string>
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const unresolved: string[] = [];
|
|
30
|
+
let candidate = absolutePath;
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
const info = await lstat(candidate);
|
|
35
|
+
if (info.isSymbolicLink()) {
|
|
36
|
+
if (seenLinks.has(candidate)) {
|
|
37
|
+
throw new Error(`Config path contains a symlink loop: ${candidate}`);
|
|
38
|
+
}
|
|
39
|
+
seenLinks.add(candidate);
|
|
40
|
+
|
|
41
|
+
const linkTarget = await readlink(candidate);
|
|
42
|
+
const absoluteTarget = isAbsolute(linkTarget)
|
|
43
|
+
? linkTarget
|
|
44
|
+
: resolve(dirname(candidate), linkTarget);
|
|
45
|
+
const canonicalTarget = await canonicalizeProspectivePath(
|
|
46
|
+
absoluteTarget,
|
|
47
|
+
seenLinks
|
|
48
|
+
);
|
|
49
|
+
return resolve(canonicalTarget, ...unresolved.reverse());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const canonicalAncestor = await realpath(candidate);
|
|
53
|
+
return resolve(canonicalAncestor, ...unresolved.reverse());
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
if (!isMissingPathError(cause)) throw cause;
|
|
56
|
+
|
|
57
|
+
const parent = dirname(candidate);
|
|
58
|
+
if (parent === candidate) return absolutePath;
|
|
59
|
+
unresolved.push(basename(candidate));
|
|
60
|
+
candidate = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve an existing path, or resolve its nearest existing ancestor while
|
|
67
|
+
* retaining unresolved path components. This makes aliases of the same config
|
|
68
|
+
* file converge before the file exists and after it is created.
|
|
69
|
+
*/
|
|
70
|
+
export async function canonicalOperationalPath(path: string): Promise<string> {
|
|
71
|
+
return canonicalizeProspectivePath(resolve(expandPath(path)), new Set());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One stable sibling lock for every writer of the selected config file. */
|
|
75
|
+
export async function resolveConfigWriteTarget(
|
|
76
|
+
configPath: string
|
|
77
|
+
): Promise<{ configPath: string; lockPath: string }> {
|
|
78
|
+
const canonicalConfigPath = await canonicalOperationalPath(configPath);
|
|
79
|
+
return {
|
|
80
|
+
configPath: canonicalConfigPath,
|
|
81
|
+
lockPath: `${canonicalConfigPath}.write.lock`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getConfigWriteLockPath(
|
|
86
|
+
configPath: string
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
return (await resolveConfigWriteTarget(configPath)).lockPath;
|
|
89
|
+
}
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
HybridSearchOptions,
|
|
12
12
|
QueryModeInput,
|
|
13
13
|
SearchMeta,
|
|
14
|
+
SearchExplain,
|
|
14
15
|
SearchResult,
|
|
15
16
|
SearchResults,
|
|
16
17
|
} from "../pipeline/types";
|
|
@@ -113,6 +114,8 @@ export interface ContextCompilerInput {
|
|
|
113
114
|
limits: ContextBudgetLimits;
|
|
114
115
|
/** One caller-owned, successfully loaded context snapshot for this plan. */
|
|
115
116
|
contextSnapshot: ContextRow[];
|
|
117
|
+
/** Internal non-canonical retrieval explanation request. */
|
|
118
|
+
explain?: boolean;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
export interface ContextRetrievalPlan {
|
|
@@ -159,6 +162,8 @@ export interface ContextEvidencePlan<
|
|
|
159
162
|
uriPrefix: string | null;
|
|
160
163
|
retrieval: ContextRetrievalPlan;
|
|
161
164
|
configuredContexts: ContextConfiguredGuidance[];
|
|
165
|
+
/** Non-canonical scoring sidecar; never included in Capsule projection. */
|
|
166
|
+
explain?: SearchExplain;
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
const compareCodeUnits = (left: string, right: string): number => {
|
|
@@ -365,6 +370,7 @@ export const planContextEvidence = async <T, P>(
|
|
|
365
370
|
limit: resultLimit === undefined ? undefined : Math.max(1, resultLimit),
|
|
366
371
|
candidateLimit:
|
|
367
372
|
rerankLimit === undefined ? undefined : Math.max(1, rerankLimit),
|
|
373
|
+
explain: input.explain,
|
|
368
374
|
})
|
|
369
375
|
);
|
|
370
376
|
}
|
|
@@ -393,6 +399,33 @@ export const planContextEvidence = async <T, P>(
|
|
|
393
399
|
.slice(0, input.limit ?? decoratedResults.length)
|
|
394
400
|
.map(({ result }) => result)
|
|
395
401
|
.sort(compareSearchResults);
|
|
402
|
+
const explainByResult = new Map<string, SearchExplain["results"][number]>();
|
|
403
|
+
for (const response of responses) {
|
|
404
|
+
for (const [index, result] of response.results.entries()) {
|
|
405
|
+
const detail = response.meta.explain?.results[index];
|
|
406
|
+
if (detail) explainByResult.set(`${result.docid}\0${result.uri}`, detail);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const explain = input.explain
|
|
410
|
+
? {
|
|
411
|
+
lines: responses.flatMap(
|
|
412
|
+
(response) => response.meta.explain?.lines ?? []
|
|
413
|
+
),
|
|
414
|
+
results: results.flatMap((result) => {
|
|
415
|
+
const detail = explainByResult.get(`${result.docid}\0${result.uri}`);
|
|
416
|
+
const retrievalRank = plannerMeta(result)?.retrievalRank;
|
|
417
|
+
return detail
|
|
418
|
+
? [
|
|
419
|
+
{
|
|
420
|
+
...detail,
|
|
421
|
+
rank: retrievalRank ?? detail.rank,
|
|
422
|
+
score: result.score,
|
|
423
|
+
},
|
|
424
|
+
]
|
|
425
|
+
: [];
|
|
426
|
+
}),
|
|
427
|
+
}
|
|
428
|
+
: undefined;
|
|
396
429
|
const uriPrefix =
|
|
397
430
|
input.uriPrefix === null || input.uriPrefix === undefined
|
|
398
431
|
? null
|
|
@@ -528,5 +561,9 @@ export const planContextEvidence = async <T, P>(
|
|
|
528
561
|
projectCanonical: (state) =>
|
|
529
562
|
deps.projectCanonical({ ...baseDraft, selection: state }),
|
|
530
563
|
});
|
|
531
|
-
return {
|
|
564
|
+
return {
|
|
565
|
+
...baseDraft,
|
|
566
|
+
...selection,
|
|
567
|
+
...(explain ? { explain } : {}),
|
|
568
|
+
};
|
|
532
569
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical identity helpers for persisted context records.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/context-identity
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const BYTE_ORDER_MARK_PATTERN = /^\uFEFF/u;
|
|
8
|
+
const CARRIAGE_RETURN_PATTERN = /\r\n?/g;
|
|
9
|
+
|
|
10
|
+
export function normalizePersistedContextText(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(BYTE_ORDER_MARK_PATTERN, "")
|
|
13
|
+
.replace(CARRIAGE_RETURN_PATTERN, "\n")
|
|
14
|
+
.normalize("NFC")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ContextRow, StorePort } from "../store/types";
|
|
2
2
|
|
|
3
3
|
import { parseUri } from "../app/constants";
|
|
4
|
-
|
|
5
|
-
const CARRIAGE_RETURN_PATTERN = /\r\n?/g;
|
|
6
|
-
const BYTE_ORDER_MARK_PATTERN = /^\uFEFF/u;
|
|
4
|
+
import { normalizePersistedContextText } from "./context-identity";
|
|
7
5
|
|
|
8
6
|
export interface ContextDocumentIdentity {
|
|
9
7
|
collection: string;
|
|
@@ -73,14 +71,6 @@ function normalizeIdentity(
|
|
|
73
71
|
return { collection, relPath };
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
function normalizeText(text: string): string {
|
|
77
|
-
return text
|
|
78
|
-
.replace(BYTE_ORDER_MARK_PATTERN, "")
|
|
79
|
-
.replace(CARRIAGE_RETURN_PATTERN, "\n")
|
|
80
|
-
.normalize("NFC")
|
|
81
|
-
.trim();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
74
|
function byteKey(text: string): string {
|
|
85
75
|
return [...new TextEncoder().encode(text)].join(",");
|
|
86
76
|
}
|
|
@@ -95,7 +85,7 @@ function normalizeContext(
|
|
|
95
85
|
context: ContextRow,
|
|
96
86
|
identity: NormalizedIdentity
|
|
97
87
|
): MatchingContext | null {
|
|
98
|
-
const text =
|
|
88
|
+
const text = normalizePersistedContextText(context.text);
|
|
99
89
|
if (!text) {
|
|
100
90
|
return null;
|
|
101
91
|
}
|
|
@@ -24,13 +24,7 @@ import { isCanonicalPathContained, validateCollectionRoot } from "./validation";
|
|
|
24
24
|
|
|
25
25
|
const INVALID_NAME_CHARS = /[^a-z0-9_-]/g;
|
|
26
26
|
const LEADING_NON_ALPHANUMERIC = /^[^a-z0-9]+/;
|
|
27
|
-
|
|
28
|
-
/^\.env(?:\.|$)/,
|
|
29
|
-
/^credentials?(?:\.|$)/,
|
|
30
|
-
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/,
|
|
31
|
-
/^secrets?(?:\.|$)/,
|
|
32
|
-
/\.(?:key|pem|p12|pfx)$/i,
|
|
33
|
-
];
|
|
27
|
+
import { hasLikelySecretPath, matchesCollectionExclusion } from "./path-rules";
|
|
34
28
|
|
|
35
29
|
export type FolderSetupErrorCode =
|
|
36
30
|
| "folder_not_found"
|
|
@@ -110,18 +104,6 @@ export function setupInjectedFailure(checkpoint: string): FolderSetupError {
|
|
|
110
104
|
);
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
function isExcluded(relPath: string, excludes: string[]): boolean {
|
|
114
|
-
const parts = relPath.split("/");
|
|
115
|
-
return excludes.some(
|
|
116
|
-
(exclude) => parts.includes(exclude) || relPath.startsWith(`${exclude}/`)
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function hasSecretRisk(relPath: string): boolean {
|
|
121
|
-
const fileName = basename(relPath).toLowerCase();
|
|
122
|
-
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
107
|
async function listFolderFiles(folder: string): Promise<string[]> {
|
|
126
108
|
const files: string[] = [];
|
|
127
109
|
const glob = new Bun.Glob("**/*");
|
|
@@ -345,7 +327,7 @@ export async function preflightFolder(
|
|
|
345
327
|
let files: string[];
|
|
346
328
|
try {
|
|
347
329
|
files = (await listFolderFiles(folder)).filter(
|
|
348
|
-
(path) => !
|
|
330
|
+
(path) => !matchesCollectionExclusion(path, excludes)
|
|
349
331
|
);
|
|
350
332
|
} catch {
|
|
351
333
|
return setupError(
|
|
@@ -361,7 +343,10 @@ export async function preflightFolder(
|
|
|
361
343
|
"Add supported documents or choose another folder."
|
|
362
344
|
);
|
|
363
345
|
}
|
|
364
|
-
if (
|
|
346
|
+
if (
|
|
347
|
+
!secretRiskAuthorized &&
|
|
348
|
+
files.some((path) => hasLikelySecretPath(path))
|
|
349
|
+
) {
|
|
365
350
|
return setupError(
|
|
366
351
|
"secret_risk",
|
|
367
352
|
`Folder contains likely credential or secret files: ${folder}`,
|
package/src/core/folder-setup.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { DEFAULT_EXCLUDES, loadConfig } from "../config";
|
|
|
12
12
|
import { defaultSyncService, withContentTypeRules } from "../ingestion";
|
|
13
13
|
import { verifyLexicalActivation } from "./activation-verifier";
|
|
14
14
|
import { applyConfigChange } from "./config-mutation";
|
|
15
|
+
import { getConfigWriteLockPath } from "./config-write-lock";
|
|
15
16
|
import {
|
|
16
17
|
type CollectionSelection,
|
|
17
18
|
type FolderSetupError,
|
|
@@ -60,6 +61,11 @@ export interface FolderSetupOptions {
|
|
|
60
61
|
name?: string;
|
|
61
62
|
exclude?: string[];
|
|
62
63
|
secretRiskAuthorized?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Preserve store-only recovery state while projecting the selected
|
|
66
|
+
* collection. Project-profile setup uses this after its own additive apply.
|
|
67
|
+
*/
|
|
68
|
+
additiveStoreProjection?: boolean;
|
|
63
69
|
/** Test-only deterministic interruption hook. */
|
|
64
70
|
failureInjection?: FolderSetupFailurePoint;
|
|
65
71
|
/** Test-only concurrency seam before the serialized config boundary. */
|
|
@@ -149,7 +155,7 @@ export async function setupFolder(
|
|
|
149
155
|
indexName: storeIdentity.indexName,
|
|
150
156
|
folderRealpath: folder,
|
|
151
157
|
});
|
|
152
|
-
const configLockPath =
|
|
158
|
+
const configLockPath = await getConfigWriteLockPath(options.configPath);
|
|
153
159
|
const unsafeOutput = await validateSetupOutputPaths(folder, [
|
|
154
160
|
{ label: "Data directory", path: options.dataDir },
|
|
155
161
|
{ label: "Setup receipt", path: receiptPath },
|
|
@@ -250,7 +256,6 @@ export async function setupFolder(
|
|
|
250
256
|
{
|
|
251
257
|
store: options.store,
|
|
252
258
|
configPath: options.configPath,
|
|
253
|
-
writeLockPath: configLockPath,
|
|
254
259
|
onConfigUpdated: (config) => {
|
|
255
260
|
activeConfig = config;
|
|
256
261
|
},
|
|
@@ -292,6 +297,29 @@ export async function setupFolder(
|
|
|
292
297
|
throw new SetupAbort(storeReceiptFailure);
|
|
293
298
|
}
|
|
294
299
|
},
|
|
300
|
+
projectStore: options.additiveStoreProjection
|
|
301
|
+
? async (store, config) => {
|
|
302
|
+
const selected = activeSelection;
|
|
303
|
+
if (!selected) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: "Setup collection selection was not established",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const collectionResult = await store.upsertCollections([
|
|
310
|
+
selected.collection,
|
|
311
|
+
]);
|
|
312
|
+
if (!collectionResult.ok) {
|
|
313
|
+
return { ok: false, error: collectionResult.error.message };
|
|
314
|
+
}
|
|
315
|
+
const contextResult = await store.upsertContexts(
|
|
316
|
+
config.contexts ?? []
|
|
317
|
+
);
|
|
318
|
+
return contextResult.ok
|
|
319
|
+
? { ok: true }
|
|
320
|
+
: { ok: false, error: contextResult.error.message };
|
|
321
|
+
}
|
|
322
|
+
: undefined,
|
|
295
323
|
},
|
|
296
324
|
async (config) => {
|
|
297
325
|
const fresh = await selectFolderCollection(
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared path-rule semantics for profile validation, setup preflight, and
|
|
3
|
+
* ingestion. Paths use repository-relative POSIX form at this boundary.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/path-rules
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const GLOB_META_PATTERN = /[*?[\]{}]/;
|
|
9
|
+
const SECRET_FILE_PATTERNS = [
|
|
10
|
+
/^\.env(?:\.|$)/i,
|
|
11
|
+
/^credentials?(?:\.|$)/i,
|
|
12
|
+
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/i,
|
|
13
|
+
/^secrets?(?:\.|$)/i,
|
|
14
|
+
/\.(?:key|pem|p12|pfx)$/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function hasLikelySecretPath(path: string): boolean {
|
|
18
|
+
const normalized = path.replaceAll("\\", "/");
|
|
19
|
+
const fileName = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
20
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasGlobMeta(pattern: string): boolean {
|
|
24
|
+
return GLOB_META_PATTERN.test(pattern);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bare values preserve historical component/prefix semantics. Values with
|
|
29
|
+
* glob metacharacters match the complete normalized relative path.
|
|
30
|
+
*/
|
|
31
|
+
export function matchesCollectionExclusion(
|
|
32
|
+
relPath: string,
|
|
33
|
+
excludes: readonly string[]
|
|
34
|
+
): boolean {
|
|
35
|
+
const normalizedPath = relPath.replaceAll("\\", "/");
|
|
36
|
+
const parts = normalizedPath.split("/");
|
|
37
|
+
|
|
38
|
+
for (const rawPattern of excludes) {
|
|
39
|
+
const pattern = rawPattern.replaceAll("\\", "/");
|
|
40
|
+
if (hasGlobMeta(pattern)) {
|
|
41
|
+
if (new Bun.Glob(pattern).match(normalizedPath)) return true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
parts.includes(pattern) ||
|
|
46
|
+
normalizedPath === pattern ||
|
|
47
|
+
normalizedPath.startsWith(`${pattern}/`)
|
|
48
|
+
) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|