sift-light 1.0.3-1 → 1.0.3-3
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 +9 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/plugins/sift-light/.claude-plugin/plugin.json +1 -1
- package/plugins/sift-light/.codex-plugin/plugin.json +1 -1
- package/plugins/sift-light/.mcp.json +1 -1
- package/plugins/sift-light/kimi.plugin.json +2 -2
- package/plugins/sift-light/omp-extension.mjs +62 -33
- package/plugins/sift-light/package.json +1 -1
- package/src/evidence-service.ts +3 -3
- package/src/mcp-server.mjs +62 -33
- package/src/path-policy.ts +22 -2
- package/src/prompt-guidelines.ts +1 -1
- package/src/request-aliases.ts +5 -0
- package/src/rg.ts +4 -3
- package/src/service.ts +5 -1
- package/src/tool-schema.ts +1 -1
- package/src/workspace-files.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.3-3 — 2026-10-04
|
|
4
|
+
|
|
5
|
+
- Keep explicit paths outside the MCP project isolated after zero matches by default, and evaluate content-search and file-discovery globs/exclusions from the selected external root. Explicit `scope: "expand"` still retries the MCP project ([#106](https://github.com/lightsifter/sift-light/issues/106)).
|
|
6
|
+
|
|
7
|
+
## 1.0.3-2 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
- Accept an empty `sourceCursor` placeholder outside `mode: "inspect"` by removing the key before mode validation, with the normalization disclosed in text and `details.requestNotes` ([#101](https://github.com/lightsifter/sift-light/issues/101)). Nonempty source continuation tokens still require inspection mode; empty inspection tokens and ordinary `cursor` validation retain their existing failure behavior.
|
|
10
|
+
- Apply the same normalization to Pi, OMP and MCP through the shared request boundary. This fixes the reported rejected request shape without attributing the placeholder to a particular model or host.
|
|
11
|
+
|
|
3
12
|
## 1.0.3-1 — 2026-09-30
|
|
4
13
|
|
|
5
14
|
- Explain missing inspection paths explicitly while retaining the `source-unavailable` status for compatibility.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ The first uncached Concept or hybrid search loads the local model and may take t
|
|
|
44
44
|
|
|
45
45
|
Ask the agent to restrict a search to one folder when that is the scope you need. If you remember only part of a filename, start by finding the file and then inspect its contents—like narrowing a cabinet down to a shelf and then a document.
|
|
46
46
|
|
|
47
|
-
Multi-word `files` queries require each word literally in the path; a single abbreviation still supports fuzzy matching. Use `hybrid` or `concept` for business intent.
|
|
47
|
+
Multi-word `files` queries require each word literally in the path; a single abbreviation still supports fuzzy matching. Use `hybrid` or `concept` for business intent. Content searches inside the active project still expand after zero results by default. An explicit path outside the project stays isolated; pass `scope: "expand"` to retry the server project. Use `scope: "strict"` to prevent in-project expansion. Expansion is announced before returned evidence.
|
|
48
48
|
|
|
49
49
|
### Know what has been shown
|
|
50
50
|
|
package/README.zh-CN.md
CHANGED
|
@@ -44,7 +44,7 @@ Concept 或 hybrid 较慢时,会在默认五秒等待窗口内返回 `status:
|
|
|
44
44
|
|
|
45
45
|
需要只查某个文件夹时,可以明确告诉 Agent 限定范围。只记得文件名的一部分,也可以先找文件,再看内容。像先确定资料柜的哪一层,再逐步缩小到要找的那一份。
|
|
46
46
|
|
|
47
|
-
`files` 多词查询要求每个词都在路径中按字面出现;单个缩写仍支持模糊匹配。业务意图使用 `hybrid` 或 `concept
|
|
47
|
+
`files` 多词查询要求每个词都在路径中按字面出现;单个缩写仍支持模糊匹配。业务意图使用 `hybrid` 或 `concept`。项目内路径仍保留零匹配后扩展到项目根目录的既有默认。显式指定到当前 MCP 项目之外的路径时,零匹配后仍默认只查该路径;需要继续查服务器项目时传 `scope: "expand"`。需要始终限定显式路径时传 `scope: "strict"`。范围扩大前会先给出提示。
|
|
48
48
|
|
|
49
49
|
### 看到了多少,说得清楚
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sift-light",
|
|
3
|
-
"version": "1.0.3-
|
|
3
|
+
"version": "1.0.3-3",
|
|
4
4
|
"description": "Require sift-light for conventional local searches while keeping development tools available.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "baoer"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"args": [
|
|
14
14
|
"--yes",
|
|
15
15
|
"--package",
|
|
16
|
-
"sift-light-runtime@npm:sift-light@1.0.3-
|
|
16
|
+
"sift-light-runtime@npm:sift-light@1.0.3-3",
|
|
17
17
|
"sift-light-mcp",
|
|
18
18
|
"--stdio"
|
|
19
19
|
],
|
|
@@ -513,9 +513,9 @@ async function consumeCappedLines(stream, onLine, options = {}) {
|
|
|
513
513
|
}
|
|
514
514
|
|
|
515
515
|
// src/path-policy.ts
|
|
516
|
-
import { realpath } from "fs/promises";
|
|
516
|
+
import { realpath, stat } from "fs/promises";
|
|
517
517
|
import { homedir } from "os";
|
|
518
|
-
import { isAbsolute, join as join2, relative, resolve, sep } from "path";
|
|
518
|
+
import { dirname, isAbsolute, join as join2, relative, resolve, sep } from "path";
|
|
519
519
|
var POSIX_SPECIAL_ROOTS = ["/dev", "/proc", "/sys"];
|
|
520
520
|
var PORTABLE_CREDENTIAL_DIRECTORY_NAMES = [
|
|
521
521
|
".ssh",
|
|
@@ -650,6 +650,28 @@ class SearchPathPolicy {
|
|
|
650
650
|
]);
|
|
651
651
|
return canonical && (!isPathInsideRoot(absolute, this.cwd) || !isPathInsideRoot(canonical, canonicalCwd)) ? canonical : absolute;
|
|
652
652
|
}
|
|
653
|
+
async allowsScopeExpansion(path, scope) {
|
|
654
|
+
if (scope === "strict")
|
|
655
|
+
return false;
|
|
656
|
+
if (scope === "expand")
|
|
657
|
+
return true;
|
|
658
|
+
const searchTarget = await this.resolveSearchTarget(path);
|
|
659
|
+
return isPathInsideRoot(searchTarget, this.cwd);
|
|
660
|
+
}
|
|
661
|
+
async ripgrepWorkingDirectory(searchPath) {
|
|
662
|
+
const absolute = resolve(this.cwd, searchPath);
|
|
663
|
+
if (isPathInsideRoot(absolute, this.cwd))
|
|
664
|
+
return this.cwd;
|
|
665
|
+
let isDirectory;
|
|
666
|
+
try {
|
|
667
|
+
isDirectory = (await stat(absolute)).isDirectory();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
670
|
+
return this.cwd;
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
return isDirectory ? absolute : dirname(absolute);
|
|
674
|
+
}
|
|
653
675
|
ripgrepGlobArguments(searchPath) {
|
|
654
676
|
const absolute = resolve(this.cwd, searchPath);
|
|
655
677
|
if (isPathInsideRoot(absolute, this.cwd))
|
|
@@ -770,7 +792,7 @@ async function runOwnedProcess(options, consumeOutput) {
|
|
|
770
792
|
|
|
771
793
|
// src/ripgrep-executable.ts
|
|
772
794
|
import { constants } from "fs";
|
|
773
|
-
import { access, stat } from "fs/promises";
|
|
795
|
+
import { access, stat as stat2 } from "fs/promises";
|
|
774
796
|
import { isAbsolute as isAbsolute2 } from "path";
|
|
775
797
|
var OVERRIDE_ENV = "SIFT_LIGHT_RG_PATH";
|
|
776
798
|
var BUNDLED_REPAIR = `Reinstall sift-light with optional dependencies enabled for this platform, or set ${OVERRIDE_ENV} to an absolute ripgrep executable path.`;
|
|
@@ -789,7 +811,7 @@ async function resolveRipgrepExecutable() {
|
|
|
789
811
|
}
|
|
790
812
|
}
|
|
791
813
|
try {
|
|
792
|
-
if (!(await
|
|
814
|
+
if (!(await stat2(executable)).isFile())
|
|
793
815
|
throw new Error("Expected an executable file");
|
|
794
816
|
await access(executable, constants.X_OK);
|
|
795
817
|
} catch (cause) {
|
|
@@ -973,12 +995,12 @@ function describeUnreadableDiagnostics(diagnostics) {
|
|
|
973
995
|
}
|
|
974
996
|
|
|
975
997
|
// src/source.ts
|
|
976
|
-
import { readFile as readFile2, realpath as realpath2, stat as
|
|
998
|
+
import { readFile as readFile2, realpath as realpath2, stat as stat3 } from "fs/promises";
|
|
977
999
|
var SOURCE_RANGE_METADATA_RESERVE_BYTES = 1024;
|
|
978
1000
|
var MAX_SOURCE_RANGE_BYTES = MAX_RESULT_BYTES - SOURCE_RANGE_METADATA_RESERVE_BYTES;
|
|
979
1001
|
async function getSourceRevision(path, onError) {
|
|
980
1002
|
try {
|
|
981
|
-
const metadata2 = await
|
|
1003
|
+
const metadata2 = await stat3(path);
|
|
982
1004
|
return sourceRevisionFromStats(metadata2);
|
|
983
1005
|
} catch (error) {
|
|
984
1006
|
onError?.(error);
|
|
@@ -1439,6 +1461,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
1439
1461
|
const searchPath = resolve4(cwd, request.path ?? ".");
|
|
1440
1462
|
const policy = new SearchPathPolicy(cwd);
|
|
1441
1463
|
const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
|
|
1464
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(validatedSearchPath);
|
|
1442
1465
|
const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
|
|
1443
1466
|
const searchTarget = isPathInsideCwd(validatedSearchPath, cwd) ? relative2(resolve4(cwd), validatedSearchPath) || "." : validatedSearchPath;
|
|
1444
1467
|
const args2 = buildRipgrepArguments(request, cwd, validatedSearchPath);
|
|
@@ -1528,7 +1551,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
1528
1551
|
...policy.ripgrepGlobArguments(validatedSearchPath),
|
|
1529
1552
|
"--",
|
|
1530
1553
|
searchTarget
|
|
1531
|
-
],
|
|
1554
|
+
], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
|
|
1532
1555
|
let ignoredFileCount = 0;
|
|
1533
1556
|
let ignoredFileSamples = [];
|
|
1534
1557
|
let filesystemCoverage = "complete";
|
|
@@ -1550,7 +1573,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
1550
1573
|
...policy.ripgrepGlobArguments(validatedSearchPath),
|
|
1551
1574
|
"--",
|
|
1552
1575
|
searchTarget
|
|
1553
|
-
],
|
|
1576
|
+
], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
|
|
1554
1577
|
const ignored = [...allCandidates.revisions.keys()].filter((path) => !before.revisions.has(path));
|
|
1555
1578
|
ignoredFileCount = ignored.length;
|
|
1556
1579
|
ignoredFileSamples = ignored.slice(0, 20).map((path) => displayPath(path, cwd).displayPath);
|
|
@@ -1579,7 +1602,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
1579
1602
|
if (before.unreadable.length > 0)
|
|
1580
1603
|
filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
|
|
1581
1604
|
await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
|
|
1582
|
-
const { code, stderr } = await runOwnedProcess({ executable, args: args2, cwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
|
|
1605
|
+
const { code, stderr } = await runOwnedProcess({ executable, args: args2, cwd: ripgrepCwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
|
|
1583
1606
|
maxLineBytes: maxEventBytes,
|
|
1584
1607
|
onLineTooLong: ({ prefix, observedBytes }) => {
|
|
1585
1608
|
const source = oversizedMatchPath(prefix, cwd);
|
|
@@ -1822,7 +1845,7 @@ function createCtagsStructureProvider(options = {}) {
|
|
|
1822
1845
|
// package.json
|
|
1823
1846
|
var package_default = {
|
|
1824
1847
|
name: "sift-light",
|
|
1825
|
-
version: "1.0.3-
|
|
1848
|
+
version: "1.0.3-3",
|
|
1826
1849
|
description: "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
|
|
1827
1850
|
keywords: [
|
|
1828
1851
|
"ai-agent",
|
|
@@ -2179,7 +2202,7 @@ class OwnedTaskQueue {
|
|
|
2179
2202
|
}
|
|
2180
2203
|
|
|
2181
2204
|
// src/concept-search.ts
|
|
2182
|
-
import { dirname as
|
|
2205
|
+
import { dirname as dirname4, join as join5, resolve as resolve12 } from "path";
|
|
2183
2206
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2184
2207
|
import { StringDecoder } from "string_decoder";
|
|
2185
2208
|
import { mkdir as mkdir2 } from "fs/promises";
|
|
@@ -2343,7 +2366,7 @@ import { extname as extname2, resolve as resolve11 } from "path";
|
|
|
2343
2366
|
import { lstat, mkdir, mkdtemp, open, rm, writeFile } from "fs/promises";
|
|
2344
2367
|
import { constants as constants2 } from "fs";
|
|
2345
2368
|
import { tmpdir } from "os";
|
|
2346
|
-
import { dirname, join as join4, parse, relative as relative4, resolve as resolve8 } from "path";
|
|
2369
|
+
import { dirname as dirname2, join as join4, parse, relative as relative4, resolve as resolve8 } from "path";
|
|
2347
2370
|
|
|
2348
2371
|
// src/workspace-files.ts
|
|
2349
2372
|
import { relative as relative3, resolve as resolve7, sep as sep2 } from "path";
|
|
@@ -2361,6 +2384,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
|
|
|
2361
2384
|
const absolutePath = resolve7(cwd, options.path ?? ".");
|
|
2362
2385
|
const policy = new SearchPathPolicy(cwd);
|
|
2363
2386
|
const searchPath = await policy.resolveSearchTarget(absolutePath);
|
|
2387
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(searchPath);
|
|
2364
2388
|
const maxFiles = options.maxFiles ?? MAX_SOURCE_REVISION_FILES;
|
|
2365
2389
|
if (!Number.isSafeInteger(maxFiles) || maxFiles < 1)
|
|
2366
2390
|
throw new SiftLightError("Candidate file limit must be a positive integer");
|
|
@@ -2386,7 +2410,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
|
|
|
2386
2410
|
"--",
|
|
2387
2411
|
searchPath
|
|
2388
2412
|
],
|
|
2389
|
-
cwd,
|
|
2413
|
+
cwd: ripgrepCwd,
|
|
2390
2414
|
...signal ? { signal } : {}
|
|
2391
2415
|
}, async (stdout) => {
|
|
2392
2416
|
let pending = Buffer.alloc(0);
|
|
@@ -2454,11 +2478,11 @@ function partitionPaths(paths) {
|
|
|
2454
2478
|
}
|
|
2455
2479
|
function relevantDirectories(cwd, paths) {
|
|
2456
2480
|
const directories = new Set;
|
|
2457
|
-
for (const path of [cwd, ...paths.map((sourcePath) =>
|
|
2481
|
+
for (const path of [cwd, ...paths.map((sourcePath) => dirname2(resolve8(cwd, sourcePath)))]) {
|
|
2458
2482
|
let current = path;
|
|
2459
2483
|
for (;; ) {
|
|
2460
2484
|
directories.add(current);
|
|
2461
|
-
const parent =
|
|
2485
|
+
const parent = dirname2(current);
|
|
2462
2486
|
if (current === parent)
|
|
2463
2487
|
break;
|
|
2464
2488
|
current = parent;
|
|
@@ -2542,12 +2566,12 @@ async function filterHistoricalPaths(cwd, paths, request, signal) {
|
|
|
2542
2566
|
for (const path of group) {
|
|
2543
2567
|
const safe = workspaceRelativePath(absoluteCwd, path);
|
|
2544
2568
|
const placeholder = resolve8(target, safe);
|
|
2545
|
-
await mkdir(
|
|
2569
|
+
await mkdir(dirname2(placeholder), { recursive: true });
|
|
2546
2570
|
await writeFile(placeholder, "");
|
|
2547
2571
|
}
|
|
2548
2572
|
for (const ignore of ignoreFiles) {
|
|
2549
2573
|
const destination = join4(tree, ignore.local);
|
|
2550
|
-
await mkdir(
|
|
2574
|
+
await mkdir(dirname2(destination), { recursive: true });
|
|
2551
2575
|
await writeFile(destination, ignore.bytes);
|
|
2552
2576
|
}
|
|
2553
2577
|
const privacy = await listWorkspaceFiles(tree, signal, { ignoreParents: false });
|
|
@@ -3540,7 +3564,7 @@ async function readWorkspaceDocument(path, cwd, signal, expected, readBudget = M
|
|
|
3540
3564
|
}
|
|
3541
3565
|
|
|
3542
3566
|
// src/syntax.ts
|
|
3543
|
-
import { dirname as
|
|
3567
|
+
import { dirname as dirname3 } from "path";
|
|
3544
3568
|
import { fileURLToPath } from "url";
|
|
3545
3569
|
|
|
3546
3570
|
// src/syntax-tree.ts
|
|
@@ -4242,7 +4266,7 @@ async function parseSyntax(path, text, signal, pattern) {
|
|
|
4242
4266
|
const result = await runOwnedProcess({
|
|
4243
4267
|
executable: process.execPath,
|
|
4244
4268
|
args: args2,
|
|
4245
|
-
cwd:
|
|
4269
|
+
cwd: dirname3(worker),
|
|
4246
4270
|
env,
|
|
4247
4271
|
signal: controller.signal,
|
|
4248
4272
|
input: Buffer.from(JSON.stringify({ language, text, pattern }))
|
|
@@ -4761,7 +4785,7 @@ async function similarities(query, passages, parent, onProgress) {
|
|
|
4761
4785
|
worker,
|
|
4762
4786
|
"--infer"
|
|
4763
4787
|
] : [worker, "--infer"],
|
|
4764
|
-
cwd:
|
|
4788
|
+
cwd: dirname4(worker),
|
|
4765
4789
|
env: { ...env, SIFT_LIGHT_CONCEPT_CACHE_STAGING_DIR: stagingRoot },
|
|
4766
4790
|
...parent ? { signal: parent } : {},
|
|
4767
4791
|
input: Buffer.from(JSON.stringify({
|
|
@@ -5183,7 +5207,7 @@ async function structuralSearch(input, access) {
|
|
|
5183
5207
|
}
|
|
5184
5208
|
|
|
5185
5209
|
// src/evidence-service.ts
|
|
5186
|
-
import { dirname as
|
|
5210
|
+
import { dirname as dirname5, extname as extname3, resolve as resolve21 } from "path";
|
|
5187
5211
|
|
|
5188
5212
|
// src/analysis-store.ts
|
|
5189
5213
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -8440,7 +8464,7 @@ async function continueSource(cursor, access, continuations, expectedPath) {
|
|
|
8440
8464
|
}
|
|
8441
8465
|
|
|
8442
8466
|
// src/evidence-validation.ts
|
|
8443
|
-
import { realpath as realpath5, stat as
|
|
8467
|
+
import { realpath as realpath5, stat as stat4 } from "fs/promises";
|
|
8444
8468
|
import { resolve as resolve19 } from "path";
|
|
8445
8469
|
|
|
8446
8470
|
// src/evidence-validity.ts
|
|
@@ -8478,9 +8502,9 @@ async function confirmWorktreeState(path, cwd, signal) {
|
|
|
8478
8502
|
const policy = new SearchPathPolicy(cwd);
|
|
8479
8503
|
try {
|
|
8480
8504
|
policy.assertPath(absolute);
|
|
8481
|
-
const before = await
|
|
8505
|
+
const before = await stat4(absolute);
|
|
8482
8506
|
const beforeCanonical = await realpath5(absolute);
|
|
8483
|
-
const after = await
|
|
8507
|
+
const after = await stat4(absolute);
|
|
8484
8508
|
const afterCanonical = await realpath5(absolute);
|
|
8485
8509
|
if (!before.isFile() || !after.isFile()) {
|
|
8486
8510
|
return { status: "stale", reason: "Source is no longer a regular file" };
|
|
@@ -8622,9 +8646,9 @@ async function validateSnapshotTarget(target, cwd, policy, signal) {
|
|
|
8622
8646
|
const result = await confirmWorktreeState(target.path, cwd, signal);
|
|
8623
8647
|
return { path: target.path, role: "source", ...result };
|
|
8624
8648
|
}
|
|
8625
|
-
const before = await
|
|
8649
|
+
const before = await stat4(absolute);
|
|
8626
8650
|
const beforeCanonical = canonical;
|
|
8627
|
-
const after = await
|
|
8651
|
+
const after = await stat4(absolute);
|
|
8628
8652
|
const afterCanonical = await realpath5(absolute);
|
|
8629
8653
|
if (!before.isFile() || !after.isFile()) {
|
|
8630
8654
|
return {
|
|
@@ -10951,10 +10975,10 @@ function searchScope(request) {
|
|
|
10951
10975
|
}
|
|
10952
10976
|
async function navigationRoot(cwd, path, signal) {
|
|
10953
10977
|
const absolute = resolve21(cwd, path);
|
|
10954
|
-
const repository = await findGitRepository(
|
|
10978
|
+
const repository = await findGitRepository(dirname5(absolute), signal);
|
|
10955
10979
|
if (repository)
|
|
10956
10980
|
return repository;
|
|
10957
|
-
return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) :
|
|
10981
|
+
return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) : dirname5(absolute);
|
|
10958
10982
|
}
|
|
10959
10983
|
function navigationFilters(input) {
|
|
10960
10984
|
const request = normalizeRequest({
|
|
@@ -11069,7 +11093,7 @@ class EvidenceService {
|
|
|
11069
11093
|
maxFiles: access.maxFiles
|
|
11070
11094
|
});
|
|
11071
11095
|
const candidates = await collect(request);
|
|
11072
|
-
if (input.changes || request.
|
|
11096
|
+
if (input.changes || request.path === undefined || !await new SearchPathPolicy(access.cwd).allowsScopeExpansion(request.path, request.scope) || candidates.files.length > 0 || candidates.partial) {
|
|
11073
11097
|
return { candidates, request };
|
|
11074
11098
|
}
|
|
11075
11099
|
const { path: requestedPath, ...projectRequest } = request;
|
|
@@ -11214,7 +11238,7 @@ class EvidenceService {
|
|
|
11214
11238
|
});
|
|
11215
11239
|
}, signal);
|
|
11216
11240
|
let chunkResults = await runChunks();
|
|
11217
|
-
if (!input.changes && input.path !== undefined && input.scope
|
|
11241
|
+
if (!input.changes && input.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(input.path, input.scope) && chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)) {
|
|
11218
11242
|
chunkResults = await runChunks(input.path.replace(/^@/, ""));
|
|
11219
11243
|
}
|
|
11220
11244
|
const reasons = new Set;
|
|
@@ -11634,6 +11658,11 @@ function normalizeRequestAliases(request) {
|
|
|
11634
11658
|
} else if (mode !== undefined) {
|
|
11635
11659
|
input = { ...rest, mode };
|
|
11636
11660
|
}
|
|
11661
|
+
if (input.mode !== "inspect" && input.sourceCursor === "") {
|
|
11662
|
+
const { sourceCursor: _sourceCursor, ...withoutSourceCursor } = input;
|
|
11663
|
+
input = withoutSourceCursor;
|
|
11664
|
+
notes.push("Empty sourceCursor is ignored outside mode=inspect.");
|
|
11665
|
+
}
|
|
11637
11666
|
if (input.sourceCursor !== undefined && input.line !== undefined) {
|
|
11638
11667
|
const { line: _line, ...withoutLine } = input;
|
|
11639
11668
|
input = withoutLine;
|
|
@@ -13378,7 +13407,7 @@ class SiftLightService {
|
|
|
13378
13407
|
throw new SiftLightError("line requires mode=inspect");
|
|
13379
13408
|
const request = normalizeRequest(input);
|
|
13380
13409
|
let scan = await this.#runRipgrep(request, cwd, signal);
|
|
13381
|
-
if (scan.totalMatches === 0 && request.path !== undefined && request.scope
|
|
13410
|
+
if (scan.totalMatches === 0 && request.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(request.path, request.scope)) {
|
|
13382
13411
|
const { path: requestedPath, ...projectRequest } = request;
|
|
13383
13412
|
scan = await this.#runRipgrep({ ...projectRequest, expandedFromPath: requestedPath }, cwd, signal);
|
|
13384
13413
|
}
|
|
@@ -13648,7 +13677,7 @@ function siftLightPromptGuidelines(structuredOutput = true) {
|
|
|
13648
13677
|
return [
|
|
13649
13678
|
SIFT_LIGHT_DISCOVERY_GUIDANCE,
|
|
13650
13679
|
`Use sift-light for read-only content search. ${SOURCE_OUTPUT_GUIDANCE} For routine development searches, start with fast exact content, filename or applicable structural modes when the request has a usable name, symbol, error text or other literal clue. Omitted mode is ordinary exact search and never loads the local embedding model. Vector search is disabled by default; concept/hybrid require vectorSearchEnabled:true in sift-light.json and an installed model. They can take tens of seconds on an uncached scope, so select them only when semantic recall is needed. Omit mode and limit for automatic detail/summary selection; use mode="matches" for ordinary match metadata.`,
|
|
13651
|
-
`An omitted path searches the project cwd. Use scope:"strict"
|
|
13680
|
+
`An omitted path searches the project cwd. Use scope:"strict" to disable zero-match expansion; by default, explicit paths inside cwd that return no matches retry from cwd and report project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd; those external targets stay isolated unless scope:"expand" opts into the same cwd retry. Protected external system areas and .git internals remain excluded. Git changes mode remains cwd-scoped.`,
|
|
13652
13681
|
`Search output includes counts, categories, ranked paths, coverage and continuation metadata. Source excerpts may contain the searched text. Use mode="inspect" or the host read capability when exact source is required for an edit or verification.`,
|
|
13653
13682
|
`Use file and directory distributions to choose evidence. Reuse the visible cursor with path or paths for match metadata; mode="summary" pages the remaining file statistics. Match counts are not relevance scores.`,
|
|
13654
13683
|
`Mode="inspect" with a direct path/line or ordinary retained match returns bounded source windows and source-revision metadata. Some retained analysis selectors return only revision metadata; use direct path/line or the host read capability when source is needed. Do not use inspection merely to obtain a citation.`,
|
|
@@ -21067,7 +21096,7 @@ var siftLightSchema = _Object_({
|
|
|
21067
21096
|
})
|
|
21068
21097
|
})),
|
|
21069
21098
|
sourceCursor: Optional(String2({
|
|
21070
|
-
description: "Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly."
|
|
21099
|
+
description: "Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly. An empty string outside mode=inspect is treated as an omitted placeholder and disclosed in request notes; nonempty tokens still require mode=inspect."
|
|
21071
21100
|
})),
|
|
21072
21101
|
symbol: Optional(String2({
|
|
21073
21102
|
description: "Syntax name for outline/imports/tests; use a concrete source file and line to narrow repeated names."
|
package/src/evidence-service.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { AnalysisStore } from "./analysis-store.js";
|
|
|
10
10
|
import type { AnalysisItem, AnalysisResultSet } from "./analysis-types.js";
|
|
11
11
|
import { abortError, CursorError, SiftLightError } from "./errors.js";
|
|
12
12
|
import { findGitRepository } from "./git-repository.js";
|
|
13
|
-
import { isPathInsideCwd, isPathInsideRoot } from "./path-policy.js";
|
|
13
|
+
import { isPathInsideCwd, isPathInsideRoot, SearchPathPolicy } from "./path-policy.js";
|
|
14
14
|
import { resolveInspectionTarget } from "./inspect.js";
|
|
15
15
|
import {
|
|
16
16
|
collectEvidenceCandidates,
|
|
@@ -457,8 +457,8 @@ export class EvidenceService {
|
|
|
457
457
|
const candidates = await collect(request);
|
|
458
458
|
if (
|
|
459
459
|
input.changes ||
|
|
460
|
-
request.scope === "strict" ||
|
|
461
460
|
request.path === undefined ||
|
|
461
|
+
!(await new SearchPathPolicy(access.cwd).allowsScopeExpansion(request.path, request.scope)) ||
|
|
462
462
|
candidates.files.length > 0 ||
|
|
463
463
|
candidates.partial
|
|
464
464
|
) {
|
|
@@ -649,7 +649,7 @@ export class EvidenceService {
|
|
|
649
649
|
if (
|
|
650
650
|
!input.changes &&
|
|
651
651
|
input.path !== undefined &&
|
|
652
|
-
input.scope
|
|
652
|
+
(await new SearchPathPolicy(cwd).allowsScopeExpansion(input.path, input.scope)) &&
|
|
653
653
|
chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)
|
|
654
654
|
) {
|
|
655
655
|
chunkResults = await runChunks(input.path.replace(/^@/, ""));
|
package/src/mcp-server.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { URL as URL2 } from "node:url";
|
|
|
7
7
|
// package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "sift-light",
|
|
10
|
-
version: "1.0.3-
|
|
10
|
+
version: "1.0.3-3",
|
|
11
11
|
description: "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
|
|
12
12
|
keywords: [
|
|
13
13
|
"ai-agent",
|
|
@@ -1580,9 +1580,9 @@ async function consumeCappedLines(stream, onLine, options = {}) {
|
|
|
1580
1580
|
}
|
|
1581
1581
|
|
|
1582
1582
|
// src/path-policy.ts
|
|
1583
|
-
import { realpath } from "node:fs/promises";
|
|
1583
|
+
import { realpath, stat } from "node:fs/promises";
|
|
1584
1584
|
import { homedir } from "node:os";
|
|
1585
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
1585
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
1586
1586
|
var POSIX_SPECIAL_ROOTS = ["/dev", "/proc", "/sys"];
|
|
1587
1587
|
var PORTABLE_CREDENTIAL_DIRECTORY_NAMES = [
|
|
1588
1588
|
".ssh",
|
|
@@ -1717,6 +1717,28 @@ class SearchPathPolicy {
|
|
|
1717
1717
|
]);
|
|
1718
1718
|
return canonical && (!isPathInsideRoot(absolute, this.cwd) || !isPathInsideRoot(canonical, canonicalCwd)) ? canonical : absolute;
|
|
1719
1719
|
}
|
|
1720
|
+
async allowsScopeExpansion(path, scope) {
|
|
1721
|
+
if (scope === "strict")
|
|
1722
|
+
return false;
|
|
1723
|
+
if (scope === "expand")
|
|
1724
|
+
return true;
|
|
1725
|
+
const searchTarget = await this.resolveSearchTarget(path);
|
|
1726
|
+
return isPathInsideRoot(searchTarget, this.cwd);
|
|
1727
|
+
}
|
|
1728
|
+
async ripgrepWorkingDirectory(searchPath) {
|
|
1729
|
+
const absolute = resolve(this.cwd, searchPath);
|
|
1730
|
+
if (isPathInsideRoot(absolute, this.cwd))
|
|
1731
|
+
return this.cwd;
|
|
1732
|
+
let isDirectory;
|
|
1733
|
+
try {
|
|
1734
|
+
isDirectory = (await stat(absolute)).isDirectory();
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1737
|
+
return this.cwd;
|
|
1738
|
+
throw error;
|
|
1739
|
+
}
|
|
1740
|
+
return isDirectory ? absolute : dirname(absolute);
|
|
1741
|
+
}
|
|
1720
1742
|
ripgrepGlobArguments(searchPath) {
|
|
1721
1743
|
const absolute = resolve(this.cwd, searchPath);
|
|
1722
1744
|
if (isPathInsideRoot(absolute, this.cwd))
|
|
@@ -1837,7 +1859,7 @@ async function runOwnedProcess(options, consumeOutput) {
|
|
|
1837
1859
|
|
|
1838
1860
|
// src/ripgrep-executable.ts
|
|
1839
1861
|
import { constants } from "node:fs";
|
|
1840
|
-
import { access, stat } from "node:fs/promises";
|
|
1862
|
+
import { access, stat as stat2 } from "node:fs/promises";
|
|
1841
1863
|
import { isAbsolute as isAbsolute2 } from "node:path";
|
|
1842
1864
|
var OVERRIDE_ENV = "SIFT_LIGHT_RG_PATH";
|
|
1843
1865
|
var BUNDLED_REPAIR = `Reinstall sift-light with optional dependencies enabled for this platform, or set ${OVERRIDE_ENV} to an absolute ripgrep executable path.`;
|
|
@@ -1856,7 +1878,7 @@ async function resolveRipgrepExecutable() {
|
|
|
1856
1878
|
}
|
|
1857
1879
|
}
|
|
1858
1880
|
try {
|
|
1859
|
-
if (!(await
|
|
1881
|
+
if (!(await stat2(executable)).isFile())
|
|
1860
1882
|
throw new Error("Expected an executable file");
|
|
1861
1883
|
await access(executable, constants.X_OK);
|
|
1862
1884
|
} catch (cause) {
|
|
@@ -1945,12 +1967,12 @@ function describeUnreadableDiagnostics(diagnostics) {
|
|
|
1945
1967
|
}
|
|
1946
1968
|
|
|
1947
1969
|
// src/source.ts
|
|
1948
|
-
import { readFile, realpath as realpath2, stat as
|
|
1970
|
+
import { readFile, realpath as realpath2, stat as stat3 } from "node:fs/promises";
|
|
1949
1971
|
var SOURCE_RANGE_METADATA_RESERVE_BYTES = 1024;
|
|
1950
1972
|
var MAX_SOURCE_RANGE_BYTES = MAX_RESULT_BYTES - SOURCE_RANGE_METADATA_RESERVE_BYTES;
|
|
1951
1973
|
async function getSourceRevision(path, onError) {
|
|
1952
1974
|
try {
|
|
1953
|
-
const metadata = await
|
|
1975
|
+
const metadata = await stat3(path);
|
|
1954
1976
|
return sourceRevisionFromStats(metadata);
|
|
1955
1977
|
} catch (error) {
|
|
1956
1978
|
onError?.(error);
|
|
@@ -2411,6 +2433,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
2411
2433
|
const searchPath = resolve4(cwd, request.path ?? ".");
|
|
2412
2434
|
const policy = new SearchPathPolicy(cwd);
|
|
2413
2435
|
const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
|
|
2436
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(validatedSearchPath);
|
|
2414
2437
|
const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
|
|
2415
2438
|
const searchTarget = isPathInsideCwd(validatedSearchPath, cwd) ? relative2(resolve4(cwd), validatedSearchPath) || "." : validatedSearchPath;
|
|
2416
2439
|
const args = buildRipgrepArguments(request, cwd, validatedSearchPath);
|
|
@@ -2500,7 +2523,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
2500
2523
|
...policy.ripgrepGlobArguments(validatedSearchPath),
|
|
2501
2524
|
"--",
|
|
2502
2525
|
searchTarget
|
|
2503
|
-
],
|
|
2526
|
+
], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
|
|
2504
2527
|
let ignoredFileCount = 0;
|
|
2505
2528
|
let ignoredFileSamples = [];
|
|
2506
2529
|
let filesystemCoverage = "complete";
|
|
@@ -2522,7 +2545,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
2522
2545
|
...policy.ripgrepGlobArguments(validatedSearchPath),
|
|
2523
2546
|
"--",
|
|
2524
2547
|
searchTarget
|
|
2525
|
-
],
|
|
2548
|
+
], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
|
|
2526
2549
|
const ignored = [...allCandidates.revisions.keys()].filter((path) => !before.revisions.has(path));
|
|
2527
2550
|
ignoredFileCount = ignored.length;
|
|
2528
2551
|
ignoredFileSamples = ignored.slice(0, 20).map((path) => displayPath(path, cwd).displayPath);
|
|
@@ -2551,7 +2574,7 @@ function createRipgrepRunner(options = {}) {
|
|
|
2551
2574
|
if (before.unreadable.length > 0)
|
|
2552
2575
|
filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
|
|
2553
2576
|
await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
|
|
2554
|
-
const { code, stderr } = await runOwnedProcess({ executable, args, cwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
|
|
2577
|
+
const { code, stderr } = await runOwnedProcess({ executable, args, cwd: ripgrepCwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
|
|
2555
2578
|
maxLineBytes: maxEventBytes,
|
|
2556
2579
|
onLineTooLong: ({ prefix, observedBytes }) => {
|
|
2557
2580
|
const source = oversizedMatchPath(prefix, cwd);
|
|
@@ -2876,7 +2899,7 @@ class OwnedTaskQueue {
|
|
|
2876
2899
|
}
|
|
2877
2900
|
|
|
2878
2901
|
// src/concept-search.ts
|
|
2879
|
-
import { dirname as
|
|
2902
|
+
import { dirname as dirname4, join as join4, resolve as resolve12 } from "node:path";
|
|
2880
2903
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2881
2904
|
import { StringDecoder } from "node:string_decoder";
|
|
2882
2905
|
import { mkdir as mkdir2 } from "node:fs/promises";
|
|
@@ -3013,7 +3036,7 @@ import { extname as extname2, resolve as resolve11 } from "node:path";
|
|
|
3013
3036
|
import { lstat, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
3014
3037
|
import { constants as constants2 } from "node:fs";
|
|
3015
3038
|
import { tmpdir } from "node:os";
|
|
3016
|
-
import { dirname, join as join3, parse, relative as relative4, resolve as resolve8 } from "node:path";
|
|
3039
|
+
import { dirname as dirname2, join as join3, parse, relative as relative4, resolve as resolve8 } from "node:path";
|
|
3017
3040
|
|
|
3018
3041
|
// src/workspace-files.ts
|
|
3019
3042
|
import { relative as relative3, resolve as resolve7, sep as sep2 } from "node:path";
|
|
@@ -3031,6 +3054,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
|
|
|
3031
3054
|
const absolutePath = resolve7(cwd, options.path ?? ".");
|
|
3032
3055
|
const policy = new SearchPathPolicy(cwd);
|
|
3033
3056
|
const searchPath = await policy.resolveSearchTarget(absolutePath);
|
|
3057
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(searchPath);
|
|
3034
3058
|
const maxFiles = options.maxFiles ?? MAX_SOURCE_REVISION_FILES;
|
|
3035
3059
|
if (!Number.isSafeInteger(maxFiles) || maxFiles < 1)
|
|
3036
3060
|
throw new SiftLightError("Candidate file limit must be a positive integer");
|
|
@@ -3056,7 +3080,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
|
|
|
3056
3080
|
"--",
|
|
3057
3081
|
searchPath
|
|
3058
3082
|
],
|
|
3059
|
-
cwd,
|
|
3083
|
+
cwd: ripgrepCwd,
|
|
3060
3084
|
...signal ? { signal } : {}
|
|
3061
3085
|
}, async (stdout) => {
|
|
3062
3086
|
let pending = Buffer.alloc(0);
|
|
@@ -3124,11 +3148,11 @@ function partitionPaths(paths) {
|
|
|
3124
3148
|
}
|
|
3125
3149
|
function relevantDirectories(cwd, paths) {
|
|
3126
3150
|
const directories = new Set;
|
|
3127
|
-
for (const path of [cwd, ...paths.map((sourcePath) =>
|
|
3151
|
+
for (const path of [cwd, ...paths.map((sourcePath) => dirname2(resolve8(cwd, sourcePath)))]) {
|
|
3128
3152
|
let current = path;
|
|
3129
3153
|
for (;; ) {
|
|
3130
3154
|
directories.add(current);
|
|
3131
|
-
const parent =
|
|
3155
|
+
const parent = dirname2(current);
|
|
3132
3156
|
if (current === parent)
|
|
3133
3157
|
break;
|
|
3134
3158
|
current = parent;
|
|
@@ -3212,12 +3236,12 @@ async function filterHistoricalPaths(cwd, paths, request, signal) {
|
|
|
3212
3236
|
for (const path of group) {
|
|
3213
3237
|
const safe = workspaceRelativePath(absoluteCwd, path);
|
|
3214
3238
|
const placeholder = resolve8(target, safe);
|
|
3215
|
-
await mkdir(
|
|
3239
|
+
await mkdir(dirname2(placeholder), { recursive: true });
|
|
3216
3240
|
await writeFile(placeholder, "");
|
|
3217
3241
|
}
|
|
3218
3242
|
for (const ignore of ignoreFiles) {
|
|
3219
3243
|
const destination = join3(tree, ignore.local);
|
|
3220
|
-
await mkdir(
|
|
3244
|
+
await mkdir(dirname2(destination), { recursive: true });
|
|
3221
3245
|
await writeFile(destination, ignore.bytes);
|
|
3222
3246
|
}
|
|
3223
3247
|
const privacy = await listWorkspaceFiles(tree, signal, { ignoreParents: false });
|
|
@@ -4210,7 +4234,7 @@ async function readWorkspaceDocument(path, cwd, signal, expected, readBudget = M
|
|
|
4210
4234
|
}
|
|
4211
4235
|
|
|
4212
4236
|
// src/syntax.ts
|
|
4213
|
-
import { dirname as
|
|
4237
|
+
import { dirname as dirname3 } from "node:path";
|
|
4214
4238
|
import { fileURLToPath } from "node:url";
|
|
4215
4239
|
|
|
4216
4240
|
// src/syntax-tree.ts
|
|
@@ -4751,7 +4775,7 @@ async function parseSyntax(path, text, signal, pattern) {
|
|
|
4751
4775
|
const result = await runOwnedProcess({
|
|
4752
4776
|
executable: process.execPath,
|
|
4753
4777
|
args,
|
|
4754
|
-
cwd:
|
|
4778
|
+
cwd: dirname3(worker),
|
|
4755
4779
|
env,
|
|
4756
4780
|
signal: controller.signal,
|
|
4757
4781
|
input: Buffer.from(JSON.stringify({ language, text, pattern }))
|
|
@@ -5270,7 +5294,7 @@ async function similarities(query, passages, parent, onProgress) {
|
|
|
5270
5294
|
worker,
|
|
5271
5295
|
"--infer"
|
|
5272
5296
|
] : [worker, "--infer"],
|
|
5273
|
-
cwd:
|
|
5297
|
+
cwd: dirname4(worker),
|
|
5274
5298
|
env: { ...env, SIFT_LIGHT_CONCEPT_CACHE_STAGING_DIR: stagingRoot },
|
|
5275
5299
|
...parent ? { signal: parent } : {},
|
|
5276
5300
|
input: Buffer.from(JSON.stringify({
|
|
@@ -5692,7 +5716,7 @@ async function structuralSearch(input, access) {
|
|
|
5692
5716
|
}
|
|
5693
5717
|
|
|
5694
5718
|
// src/evidence-service.ts
|
|
5695
|
-
import { dirname as
|
|
5719
|
+
import { dirname as dirname5, extname as extname3, resolve as resolve21 } from "node:path";
|
|
5696
5720
|
|
|
5697
5721
|
// src/analysis-store.ts
|
|
5698
5722
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
@@ -8846,7 +8870,7 @@ async function continueSource(cursor, access, continuations, expectedPath) {
|
|
|
8846
8870
|
}
|
|
8847
8871
|
|
|
8848
8872
|
// src/evidence-validation.ts
|
|
8849
|
-
import { realpath as realpath5, stat as
|
|
8873
|
+
import { realpath as realpath5, stat as stat4 } from "node:fs/promises";
|
|
8850
8874
|
import { resolve as resolve19 } from "node:path";
|
|
8851
8875
|
|
|
8852
8876
|
// src/evidence-validity.ts
|
|
@@ -8884,9 +8908,9 @@ async function confirmWorktreeState(path, cwd, signal) {
|
|
|
8884
8908
|
const policy = new SearchPathPolicy(cwd);
|
|
8885
8909
|
try {
|
|
8886
8910
|
policy.assertPath(absolute);
|
|
8887
|
-
const before = await
|
|
8911
|
+
const before = await stat4(absolute);
|
|
8888
8912
|
const beforeCanonical = await realpath5(absolute);
|
|
8889
|
-
const after = await
|
|
8913
|
+
const after = await stat4(absolute);
|
|
8890
8914
|
const afterCanonical = await realpath5(absolute);
|
|
8891
8915
|
if (!before.isFile() || !after.isFile()) {
|
|
8892
8916
|
return { status: "stale", reason: "Source is no longer a regular file" };
|
|
@@ -9028,9 +9052,9 @@ async function validateSnapshotTarget(target, cwd, policy, signal) {
|
|
|
9028
9052
|
const result = await confirmWorktreeState(target.path, cwd, signal);
|
|
9029
9053
|
return { path: target.path, role: "source", ...result };
|
|
9030
9054
|
}
|
|
9031
|
-
const before = await
|
|
9055
|
+
const before = await stat4(absolute);
|
|
9032
9056
|
const beforeCanonical = canonical;
|
|
9033
|
-
const after = await
|
|
9057
|
+
const after = await stat4(absolute);
|
|
9034
9058
|
const afterCanonical = await realpath5(absolute);
|
|
9035
9059
|
if (!before.isFile() || !after.isFile()) {
|
|
9036
9060
|
return {
|
|
@@ -10983,10 +11007,10 @@ function searchScope(request) {
|
|
|
10983
11007
|
}
|
|
10984
11008
|
async function navigationRoot(cwd, path, signal) {
|
|
10985
11009
|
const absolute = resolve21(cwd, path);
|
|
10986
|
-
const repository = await findGitRepository(
|
|
11010
|
+
const repository = await findGitRepository(dirname5(absolute), signal);
|
|
10987
11011
|
if (repository)
|
|
10988
11012
|
return repository;
|
|
10989
|
-
return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) :
|
|
11013
|
+
return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) : dirname5(absolute);
|
|
10990
11014
|
}
|
|
10991
11015
|
function navigationFilters(input) {
|
|
10992
11016
|
const request = normalizeRequest({
|
|
@@ -11101,7 +11125,7 @@ class EvidenceService {
|
|
|
11101
11125
|
maxFiles: access.maxFiles
|
|
11102
11126
|
});
|
|
11103
11127
|
const candidates = await collect(request);
|
|
11104
|
-
if (input.changes || request.
|
|
11128
|
+
if (input.changes || request.path === undefined || !await new SearchPathPolicy(access.cwd).allowsScopeExpansion(request.path, request.scope) || candidates.files.length > 0 || candidates.partial) {
|
|
11105
11129
|
return { candidates, request };
|
|
11106
11130
|
}
|
|
11107
11131
|
const { path: requestedPath, ...projectRequest } = request;
|
|
@@ -11246,7 +11270,7 @@ class EvidenceService {
|
|
|
11246
11270
|
});
|
|
11247
11271
|
}, signal);
|
|
11248
11272
|
let chunkResults = await runChunks();
|
|
11249
|
-
if (!input.changes && input.path !== undefined && input.scope
|
|
11273
|
+
if (!input.changes && input.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(input.path, input.scope) && chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)) {
|
|
11250
11274
|
chunkResults = await runChunks(input.path.replace(/^@/, ""));
|
|
11251
11275
|
}
|
|
11252
11276
|
const reasons = new Set;
|
|
@@ -11666,6 +11690,11 @@ function normalizeRequestAliases(request) {
|
|
|
11666
11690
|
} else if (mode !== undefined) {
|
|
11667
11691
|
input = { ...rest, mode };
|
|
11668
11692
|
}
|
|
11693
|
+
if (input.mode !== "inspect" && input.sourceCursor === "") {
|
|
11694
|
+
const { sourceCursor: _sourceCursor, ...withoutSourceCursor } = input;
|
|
11695
|
+
input = withoutSourceCursor;
|
|
11696
|
+
notes.push("Empty sourceCursor is ignored outside mode=inspect.");
|
|
11697
|
+
}
|
|
11669
11698
|
if (input.sourceCursor !== undefined && input.line !== undefined) {
|
|
11670
11699
|
const { line: _line, ...withoutLine } = input;
|
|
11671
11700
|
input = withoutLine;
|
|
@@ -13410,7 +13439,7 @@ class SiftLightService {
|
|
|
13410
13439
|
throw new SiftLightError("line requires mode=inspect");
|
|
13411
13440
|
const request = normalizeRequest(input);
|
|
13412
13441
|
let scan = await this.#runRipgrep(request, cwd, signal);
|
|
13413
|
-
if (scan.totalMatches === 0 && request.path !== undefined && request.scope
|
|
13442
|
+
if (scan.totalMatches === 0 && request.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(request.path, request.scope)) {
|
|
13414
13443
|
const { path: requestedPath, ...projectRequest } = request;
|
|
13415
13444
|
scan = await this.#runRipgrep({ ...projectRequest, expandedFromPath: requestedPath }, cwd, signal);
|
|
13416
13445
|
}
|
|
@@ -13680,7 +13709,7 @@ function siftLightPromptGuidelines(structuredOutput = true) {
|
|
|
13680
13709
|
return [
|
|
13681
13710
|
SIFT_LIGHT_DISCOVERY_GUIDANCE,
|
|
13682
13711
|
`Use sift-light for read-only content search. ${SOURCE_OUTPUT_GUIDANCE} For routine development searches, start with fast exact content, filename or applicable structural modes when the request has a usable name, symbol, error text or other literal clue. Omitted mode is ordinary exact search and never loads the local embedding model. Vector search is disabled by default; concept/hybrid require vectorSearchEnabled:true in sift-light.json and an installed model. They can take tens of seconds on an uncached scope, so select them only when semantic recall is needed. Omit mode and limit for automatic detail/summary selection; use mode="matches" for ordinary match metadata.`,
|
|
13683
|
-
`An omitted path searches the project cwd. Use scope:"strict"
|
|
13712
|
+
`An omitted path searches the project cwd. Use scope:"strict" to disable zero-match expansion; by default, explicit paths inside cwd that return no matches retry from cwd and report project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd; those external targets stay isolated unless scope:"expand" opts into the same cwd retry. Protected external system areas and .git internals remain excluded. Git changes mode remains cwd-scoped.`,
|
|
13684
13713
|
`Search output includes counts, categories, ranked paths, coverage and continuation metadata. Source excerpts may contain the searched text. Use mode="inspect" or the host read capability when exact source is required for an edit or verification.`,
|
|
13685
13714
|
`Use file and directory distributions to choose evidence. Reuse the visible cursor with path or paths for match metadata; mode="summary" pages the remaining file statistics. Match counts are not relevance scores.`,
|
|
13686
13715
|
`Mode="inspect" with a direct path/line or ordinary retained match returns bounded source windows and source-revision metadata. Some retained analysis selectors return only revision metadata; use direct path/line or the host read capability when source is needed. Do not use inspection merely to obtain a citation.`,
|
|
@@ -13781,7 +13810,7 @@ var siftLightSchema = Type.Object({
|
|
|
13781
13810
|
})
|
|
13782
13811
|
})),
|
|
13783
13812
|
sourceCursor: Type.Optional(Type.String({
|
|
13784
|
-
description: "Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly."
|
|
13813
|
+
description: "Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly. An empty string outside mode=inspect is treated as an omitted placeholder and disclosed in request notes; nonempty tokens still require mode=inspect."
|
|
13785
13814
|
})),
|
|
13786
13815
|
symbol: Type.Optional(Type.String({
|
|
13787
13816
|
description: "Syntax name for outline/imports/tests; use a concrete source file and line to narrow repeated names."
|
package/src/path-policy.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { realpath } from "node:fs/promises";
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { SiftLightError } from "./errors.js";
|
|
5
5
|
|
|
6
6
|
const POSIX_SPECIAL_ROOTS = ["/dev", "/proc", "/sys"] as const;
|
|
@@ -173,6 +173,26 @@ export class SearchPathPolicy {
|
|
|
173
173
|
: absolute;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
async allowsScopeExpansion(path: string, scope?: "strict" | "expand"): Promise<boolean> {
|
|
177
|
+
if (scope === "strict") return false;
|
|
178
|
+
if (scope === "expand") return true;
|
|
179
|
+
const searchTarget = await this.resolveSearchTarget(path);
|
|
180
|
+
return isPathInsideRoot(searchTarget, this.cwd);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async ripgrepWorkingDirectory(searchPath: string): Promise<string> {
|
|
184
|
+
const absolute = resolve(this.cwd, searchPath);
|
|
185
|
+
if (isPathInsideRoot(absolute, this.cwd)) return this.cwd;
|
|
186
|
+
let isDirectory: boolean;
|
|
187
|
+
try {
|
|
188
|
+
isDirectory = (await stat(absolute)).isDirectory();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return this.cwd;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
return isDirectory ? absolute : dirname(absolute);
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
ripgrepGlobArguments(searchPath: string): string[] {
|
|
177
197
|
const absolute = resolve(this.cwd, searchPath);
|
|
178
198
|
if (isPathInsideRoot(absolute, this.cwd)) return [];
|
package/src/prompt-guidelines.ts
CHANGED
|
@@ -10,7 +10,7 @@ export function siftLightPromptGuidelines(structuredOutput = true): string[] {
|
|
|
10
10
|
return [
|
|
11
11
|
SIFT_LIGHT_DISCOVERY_GUIDANCE,
|
|
12
12
|
`Use sift-light for read-only content search. ${SOURCE_OUTPUT_GUIDANCE} For routine development searches, start with fast exact content, filename or applicable structural modes when the request has a usable name, symbol, error text or other literal clue. Omitted mode is ordinary exact search and never loads the local embedding model. Vector search is disabled by default; concept/hybrid require vectorSearchEnabled:true in sift-light.json and an installed model. They can take tens of seconds on an uncached scope, so select them only when semantic recall is needed. Omit mode and limit for automatic detail/summary selection; use mode="matches" for ordinary match metadata.`,
|
|
13
|
-
`An omitted path searches the project cwd. Use scope:"strict"
|
|
13
|
+
`An omitted path searches the project cwd. Use scope:"strict" to disable zero-match expansion; by default, explicit paths inside cwd that return no matches retry from cwd and report project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd; those external targets stay isolated unless scope:"expand" opts into the same cwd retry. Protected external system areas and .git internals remain excluded. Git changes mode remains cwd-scoped.`,
|
|
14
14
|
`Search output includes counts, categories, ranked paths, coverage and continuation metadata. Source excerpts may contain the searched text. Use mode="inspect" or the host read capability when exact source is required for an edit or verification.`,
|
|
15
15
|
`Use file and directory distributions to choose evidence. Reuse the visible cursor with path or paths for match metadata; mode="summary" pages the remaining file statistics. Match counts are not relevance scores.`,
|
|
16
16
|
`Mode="inspect" with a direct path/line or ordinary retained match returns bounded source windows and source-revision metadata. Some retained analysis selectors return only revision metadata; use direct path/line or the host read capability when source is needed. Do not use inspection merely to obtain a citation.`,
|
package/src/request-aliases.ts
CHANGED
|
@@ -39,6 +39,11 @@ export function normalizeRequestAliases(request: SiftLightRequest): NormalizedRe
|
|
|
39
39
|
} else if (mode !== undefined) {
|
|
40
40
|
input = { ...rest, mode };
|
|
41
41
|
}
|
|
42
|
+
if (input.mode !== "inspect" && input.sourceCursor === "") {
|
|
43
|
+
const { sourceCursor: _sourceCursor, ...withoutSourceCursor } = input;
|
|
44
|
+
input = withoutSourceCursor;
|
|
45
|
+
notes.push("Empty sourceCursor is ignored outside mode=inspect.");
|
|
46
|
+
}
|
|
42
47
|
if (input.sourceCursor !== undefined && input.line !== undefined) {
|
|
43
48
|
const { line: _line, ...withoutLine } = input;
|
|
44
49
|
input = withoutLine;
|
package/src/rg.ts
CHANGED
|
@@ -332,6 +332,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
|
|
|
332
332
|
const searchPath = resolve(cwd, request.path ?? ".");
|
|
333
333
|
const policy = new SearchPathPolicy(cwd);
|
|
334
334
|
const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
|
|
335
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(validatedSearchPath);
|
|
335
336
|
const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
|
|
336
337
|
const searchTarget = isPathInsideCwd(validatedSearchPath, cwd)
|
|
337
338
|
? relative(resolve(cwd), validatedSearchPath) || "."
|
|
@@ -430,7 +431,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
|
|
|
430
431
|
"--",
|
|
431
432
|
searchTarget,
|
|
432
433
|
],
|
|
433
|
-
|
|
434
|
+
ripgrepCwd,
|
|
434
435
|
maxSourceRevisionFiles,
|
|
435
436
|
signal,
|
|
436
437
|
request.redact,
|
|
@@ -463,7 +464,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
|
|
|
463
464
|
"--",
|
|
464
465
|
searchTarget,
|
|
465
466
|
],
|
|
466
|
-
|
|
467
|
+
ripgrepCwd,
|
|
467
468
|
maxSourceRevisionFiles,
|
|
468
469
|
signal,
|
|
469
470
|
request.redact,
|
|
@@ -501,7 +502,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
|
|
|
501
502
|
filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
|
|
502
503
|
await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
|
|
503
504
|
const { code, stderr } = await runOwnedProcess(
|
|
504
|
-
{ executable, args, cwd, ...(signal ? { signal } : {}) },
|
|
505
|
+
{ executable, args, cwd: ripgrepCwd, ...(signal ? { signal } : {}) },
|
|
505
506
|
(stdout) =>
|
|
506
507
|
consumeCappedLines(stdout, onLine, {
|
|
507
508
|
maxLineBytes: maxEventBytes,
|
package/src/service.ts
CHANGED
|
@@ -598,7 +598,11 @@ export class SiftLightService {
|
|
|
598
598
|
|
|
599
599
|
const request = normalizeRequest(input);
|
|
600
600
|
let scan = await this.#runRipgrep(request, cwd, signal);
|
|
601
|
-
if (
|
|
601
|
+
if (
|
|
602
|
+
scan.totalMatches === 0 &&
|
|
603
|
+
request.path !== undefined &&
|
|
604
|
+
(await new SearchPathPolicy(cwd).allowsScopeExpansion(request.path, request.scope))
|
|
605
|
+
) {
|
|
602
606
|
const { path: requestedPath, ...projectRequest } = request;
|
|
603
607
|
scan = await this.#runRipgrep(
|
|
604
608
|
{ ...projectRequest, expandedFromPath: requestedPath },
|
package/src/tool-schema.ts
CHANGED
|
@@ -127,7 +127,7 @@ export const siftLightSchema = Type.Object({
|
|
|
127
127
|
sourceCursor: Type.Optional(
|
|
128
128
|
Type.String({
|
|
129
129
|
description:
|
|
130
|
-
"Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly.",
|
|
130
|
+
"Missing-source continuation token. Copy nextRequest exactly: mode=inspect plus sourceCursor only. Same token replays the same page; changed or expired sources fail clearly. An empty string outside mode=inspect is treated as an omitted placeholder and disclosed in request notes; nonempty tokens still require mode=inspect.",
|
|
131
131
|
}),
|
|
132
132
|
),
|
|
133
133
|
symbol: Type.Optional(
|
package/src/workspace-files.ts
CHANGED
|
@@ -55,6 +55,7 @@ export async function listWorkspaceFiles(
|
|
|
55
55
|
const absolutePath = resolve(cwd, options.path ?? ".");
|
|
56
56
|
const policy = new SearchPathPolicy(cwd);
|
|
57
57
|
const searchPath = await policy.resolveSearchTarget(absolutePath);
|
|
58
|
+
const ripgrepCwd = await policy.ripgrepWorkingDirectory(searchPath);
|
|
58
59
|
const maxFiles = options.maxFiles ?? MAX_SOURCE_REVISION_FILES;
|
|
59
60
|
if (!Number.isSafeInteger(maxFiles) || maxFiles < 1)
|
|
60
61
|
throw new SiftLightError("Candidate file limit must be a positive integer");
|
|
@@ -81,7 +82,7 @@ export async function listWorkspaceFiles(
|
|
|
81
82
|
"--",
|
|
82
83
|
searchPath,
|
|
83
84
|
],
|
|
84
|
-
cwd,
|
|
85
|
+
cwd: ripgrepCwd,
|
|
85
86
|
...(signal ? { signal } : {}),
|
|
86
87
|
},
|
|
87
88
|
async (stdout) => {
|