@catheadowl/ukp 0.1.0 → 0.1.1
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 +2 -1
- package/package.json +1 -1
- package/src/capabilities/propose.ts +2 -1
- package/src/capabilities/rename-recovery.ts +8 -4
- package/src/capabilities/rg.ts +6 -4
- package/src/capabilities/search.ts +232 -66
- package/src/commands/guide-content/service-qmd.txt +8 -3
- package/src/commands/init.ts +7 -0
- package/src/commands/inventory.ts +4 -0
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Endpoint commands:
|
|
|
135
135
|
| `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands endpoints explicitly declared as dependencies (authority/context). Results hand off via `ukp://` references. |
|
|
136
136
|
| `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or `docid` handoff key. |
|
|
137
137
|
| `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
|
|
138
|
-
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary
|
|
138
|
+
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning (a single-endpoint run exits non-zero). |
|
|
139
139
|
| `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
|
|
140
140
|
|
|
141
141
|
Registry commands:
|
|
@@ -204,5 +204,6 @@ and the hardening posture — lives in the
|
|
|
204
204
|
SSH/HTTPS, the host door, Caddy and Tailscale fronting, the certificate
|
|
205
205
|
pinning lifecycle, and the hardening posture.
|
|
206
206
|
- [Contributing](https://github.com/CatheadOwl/ukp/blob/main/CONTRIBUTING.md) — development setup and release packaging.
|
|
207
|
+
- [Changelog](https://github.com/CatheadOwl/ukp/blob/main/CHANGELOG.md) — what changed per release.
|
|
207
208
|
- Repository: https://github.com/CatheadOwl/ukp ·
|
|
208
209
|
Issues: https://github.com/CatheadOwl/ukp/issues
|
package/package.json
CHANGED
|
@@ -441,7 +441,8 @@ export function runPropose(request: ProposeRequest, context: ProposeContext): Pr
|
|
|
441
441
|
ok: false,
|
|
442
442
|
failure: {
|
|
443
443
|
errorClass: "capability-undeclared",
|
|
444
|
-
message: `endpoint '${binding.name}' does not declare the propose capability
|
|
444
|
+
message: `endpoint '${binding.name}' does not declare the propose capability`
|
|
445
|
+
+ ` — declare [capabilities.propose] in the Service Manifest (.ukp/service.toml); 'ukp guide propose' walks through it`,
|
|
445
446
|
},
|
|
446
447
|
};
|
|
447
448
|
}
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { buildQmdInvocation, defaultQmdCommand, providerTimeoutMs } from "./qmd.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { buildEndpointFileIndex, docidOf, providerLocationOf, verifiedEndpointRouteOf } from "./search.ts";
|
|
7
7
|
|
|
8
8
|
// ADR 0020: read-time layered rename recovery. This module owns the
|
|
9
9
|
// miss-path descent (L1 git-derived -> L2 search re-anchor -> exhausted) and
|
|
@@ -160,13 +160,17 @@ function reanchorViaSearch(request: RecoveryRequest): string[] {
|
|
|
160
160
|
return [];
|
|
161
161
|
}
|
|
162
162
|
if (!Array.isArray(nativeResults)) return [];
|
|
163
|
+
// ADR 0025: recalled routes are content-verified against each hit's docid
|
|
164
|
+
// (same verified mapping as search emission) — a wrong or drifted
|
|
165
|
+
// candidate never enters the recovery candidate list.
|
|
166
|
+
const index = buildEndpointFileIndex(request.serviceFolder);
|
|
163
167
|
const routes = new Set<string>();
|
|
164
168
|
for (const item of nativeResults) {
|
|
165
169
|
const location = providerLocationOf(item);
|
|
166
170
|
if (!location) continue;
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
routes.add(
|
|
171
|
+
const { relPath } = verifiedEndpointRouteOf(location, request.serviceFolder, docidOf(item), index);
|
|
172
|
+
if (relPath && relPath.split("/").pop() === basename && relPath !== request.route) {
|
|
173
|
+
routes.add(relPath);
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
return [...routes].sort();
|
package/src/capabilities/rg.ts
CHANGED
|
@@ -291,10 +291,12 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
|
|
|
291
291
|
continue;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
// Explicit search root:
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
294
|
+
// Explicit search root: with no path operand rg searches stdin instead
|
|
295
|
+
// of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
|
|
296
|
+
// confirmed on the ali repro host: spawned rg read an empty pipe and
|
|
297
|
+
// exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
|
|
298
|
+
// "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
|
|
299
|
+
// stripped at both intakes below.
|
|
298
300
|
const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
|
|
299
301
|
cwd: endpoint.folder,
|
|
300
302
|
encoding: "utf8",
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
existsSync,
|
|
5
6
|
openSync,
|
|
7
|
+
readdirSync,
|
|
6
8
|
readFileSync,
|
|
7
9
|
realpathSync,
|
|
8
10
|
statSync,
|
|
@@ -457,6 +459,12 @@ function runHumanMode(
|
|
|
457
459
|
continue;
|
|
458
460
|
}
|
|
459
461
|
outcome.status = providerOutput ? "succeeded" : "no_matches";
|
|
462
|
+
if (outcome.status === "no_matches") {
|
|
463
|
+
// An empty provider result is indistinguishable from an unconfigured
|
|
464
|
+
// provider to a first-run caller — surface the setup path as guidance
|
|
465
|
+
// (search's own contract stays: no matches is a result, not a failure).
|
|
466
|
+
warnings.push(`endpoint '${endpoint.name}' returned no matches; if the QMD provider has not been set up for this Service yet, see 'ukp guide service qmd'`);
|
|
467
|
+
}
|
|
460
468
|
}
|
|
461
469
|
return {
|
|
462
470
|
query: parsed.request.query,
|
|
@@ -490,6 +498,8 @@ interface QmdReferenceMapping {
|
|
|
490
498
|
status: "read_ready" | "provider_only";
|
|
491
499
|
read_adapter?: "qmd";
|
|
492
500
|
ukp_uri?: string;
|
|
501
|
+
/** REF-4 (ADR 0025): why no `ukp_uri` was emitted for this result. */
|
|
502
|
+
ukp_uri_omission_reason?: UriOmissionReason;
|
|
493
503
|
reason?: string;
|
|
494
504
|
}
|
|
495
505
|
|
|
@@ -504,7 +514,10 @@ function maybeLine(line: number | undefined): { line?: number } {
|
|
|
504
514
|
return line ? { line } : {};
|
|
505
515
|
}
|
|
506
516
|
|
|
507
|
-
|
|
517
|
+
/** The bare 6-hex content fingerprint of one QMD result (`#` stripped and
|
|
518
|
+
* body-validated); exported for the rename-recovery L2 re-anchor, which
|
|
519
|
+
* verifies recalled candidates against the same fingerprint (ADR 0025). */
|
|
520
|
+
export function docidOf(result: unknown): string | undefined {
|
|
508
521
|
if (typeof result !== "object" || result === null || !("docid" in result)) return undefined;
|
|
509
522
|
const docid = (result as { docid?: unknown }).docid;
|
|
510
523
|
if (typeof docid !== "string") return undefined;
|
|
@@ -539,91 +552,223 @@ function encodeUkpUriSegment(segment: string): string {
|
|
|
539
552
|
}
|
|
540
553
|
|
|
541
554
|
/**
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
* (
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
555
|
+
* Verified emission (ADR 0025 / D-082; REF-1 + REF-2 in
|
|
556
|
+
* `docs/concepts/reference-integrity.md`): a `ukp://` URI is emitted only
|
|
557
|
+
* when a candidate endpoint-local file's content hash matches the hit's
|
|
558
|
+
* docid — `sha256(raw on-disk bytes)[0:6]`, the provider's content
|
|
559
|
+
* fingerprint (empirically pinned 2026-09-17). Existence alone never
|
|
560
|
+
* suffices (REF-2: existence ≠ identity); enumeration may guess
|
|
561
|
+
* (normalization-insensitive matching — the provider location vocabulary is
|
|
562
|
+
* not filesystem-literal, e.g. `session_format` → `session-format`), the
|
|
563
|
+
* hash decides; every failure degrades to no-uri with an auditable reason
|
|
564
|
+
* (REF-4), never to a wrong uri. If the provider ever changes its hash
|
|
565
|
+
* scheme, verification fails closed: coverage drops, correctness never
|
|
566
|
+
* breaks.
|
|
553
567
|
*/
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
|
|
569
|
+
/** REF-4: why a `ukp_uri` was not emitted for a result. */
|
|
570
|
+
export type UriOmissionReason =
|
|
571
|
+
| "no-docid"
|
|
572
|
+
| "no-candidate"
|
|
573
|
+
| "hash-mismatch"
|
|
574
|
+
| "ambiguous"
|
|
575
|
+
| "out-of-folder";
|
|
576
|
+
|
|
577
|
+
export interface VerifiedRoute {
|
|
578
|
+
relPath?: string;
|
|
579
|
+
reason?: UriOmissionReason;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Enumeration-only normalization (deliberately loose — the hash is the
|
|
583
|
+
* authority): folds the provider's observed `_`→`-` location vocabulary and
|
|
584
|
+
* case so wrong guesses stay cheap and harmless. */
|
|
585
|
+
function normalizeSegmentForEnumeration(segment: string): string {
|
|
586
|
+
return segment.toLowerCase().replaceAll("_", "-");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Endpoint file index: normalized endpoint-relative path → real rel paths.
|
|
590
|
+
* Built once per emission call (per endpoint). Symlinked files resolving
|
|
591
|
+
* back inside the folder are included (mirroring the read hit path);
|
|
592
|
+
* symlinked directories are not descended; `.git`/`.qmd` internals are
|
|
593
|
+
* skipped. The index only *generates candidates* — the resolved-containment
|
|
594
|
+
* safety gate and the content hash below decide. */
|
|
595
|
+
export type EndpointFileIndex = Map<string, string[]>;
|
|
596
|
+
|
|
597
|
+
/** Structural shape of a `readdirSync(withFileTypes)` entry as the index
|
|
598
|
+
* uses it (avoids the Dirent<Buffer> overload inference). */
|
|
599
|
+
interface IndexDirent {
|
|
600
|
+
name: string;
|
|
601
|
+
isDirectory(): boolean;
|
|
602
|
+
isFile(): boolean;
|
|
603
|
+
isSymbolicLink(): boolean;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function buildEndpointFileIndex(endpointFolder: string): EndpointFileIndex {
|
|
607
|
+
const index: EndpointFileIndex = new Map();
|
|
608
|
+
let realRoot: string;
|
|
609
|
+
try {
|
|
610
|
+
realRoot = realpathSync(endpointFolder);
|
|
611
|
+
} catch {
|
|
612
|
+
return index;
|
|
613
|
+
}
|
|
614
|
+
const walk = (directory: string, prefix: string): void => {
|
|
615
|
+
let entries: IndexDirent[];
|
|
616
|
+
try {
|
|
617
|
+
entries = readdirSync(directory, { withFileTypes: true }) as unknown as IndexDirent[];
|
|
618
|
+
} catch {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
for (const entry of entries) {
|
|
622
|
+
if (entry.name === ".git" || entry.name === ".qmd") continue;
|
|
623
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
624
|
+
if (entry.isDirectory()) {
|
|
625
|
+
walk(join(directory, entry.name), rel);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (entry.isSymbolicLink()) {
|
|
629
|
+
// Include only symlinked files that resolve back inside the root
|
|
630
|
+
// (G5: a symlink escaping the folder must never yield a slot).
|
|
631
|
+
try {
|
|
632
|
+
if (!statSync(join(directory, entry.name)).isFile()) continue;
|
|
633
|
+
if (!isInsideRealRoot(realRoot, realpathSync(join(directory, entry.name)))) continue;
|
|
634
|
+
} catch {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
} else if (!entry.isFile()) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const normalized = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
|
|
641
|
+
const bucket = index.get(normalized);
|
|
642
|
+
if (bucket === undefined) index.set(normalized, [rel]);
|
|
643
|
+
else bucket.push(rel);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
walk(realRoot, "");
|
|
647
|
+
return index;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function sha256Prefix6Of(file: string): string | undefined {
|
|
651
|
+
try {
|
|
652
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 6);
|
|
653
|
+
} catch {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* ADR 0025 core: map a provider location to an endpoint-relative route only
|
|
660
|
+
* when a candidate file verifies as the hit's content. Layered policy over
|
|
661
|
+
* the shared primitives (ADR 0023): candidate generation (path-shaped by
|
|
662
|
+
* folder-relative containment, collection-shaped by normalization-insensitive
|
|
663
|
+
* suffix match — the collection-root assumption that broke ISSUE-014 is
|
|
664
|
+
* gone) → resolved containment + isFile safety gate → sha256-prefix
|
|
665
|
+
* adjudication against the docid. Zero or multiple verified matches decline
|
|
666
|
+
* (ambiguity never emits).
|
|
667
|
+
*/
|
|
668
|
+
export function verifiedEndpointRouteOf(
|
|
669
|
+
providerLocation: string,
|
|
670
|
+
endpointFolder: string,
|
|
671
|
+
docid: string | undefined,
|
|
672
|
+
index: EndpointFileIndex,
|
|
673
|
+
): VerifiedRoute {
|
|
674
|
+
if (!providerLocation.startsWith("qmd://")) return {};
|
|
675
|
+
if (docid === undefined) return { reason: "no-docid" };
|
|
559
676
|
const rest = providerLocation.slice("qmd://".length);
|
|
560
|
-
let
|
|
677
|
+
let candidates: string[];
|
|
561
678
|
if (isAbsoluteShapedPath(rest)) {
|
|
679
|
+
// Path-shaped (collection name is the full on-disk path): folder-relative
|
|
680
|
+
// containment, with the cross-drive / UNC escape rejected explicitly
|
|
681
|
+
// (`relative()` returns the absolute target itself there).
|
|
562
682
|
const rel = relative(resolve(endpointFolder), resolve(rest));
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
candidate = rel.split(/[\\/]/).join("/");
|
|
683
|
+
if (isAbsoluteShapedPath(rel)) return { reason: "out-of-folder" };
|
|
684
|
+
const candidate = rel.split(/[\\/]/).join("/");
|
|
685
|
+
if (candidate === "" || candidate.split("/").includes("..")) return { reason: "out-of-folder" };
|
|
686
|
+
candidates = [candidate];
|
|
568
687
|
} else {
|
|
688
|
+
// Collection-shaped: strip the collection name, then match the remainder
|
|
689
|
+
// against the folder's normalized paths as an exact-or-suffix key. The
|
|
690
|
+
// suffix arm is what recovers subfolder-rooted collections (ISSUE-014
|
|
691
|
+
// false negatives) without knowing any collection root.
|
|
569
692
|
const firstSlash = rest.indexOf("/");
|
|
570
|
-
if (firstSlash <= 0) return
|
|
571
|
-
|
|
693
|
+
if (firstSlash <= 0) return { reason: "no-candidate" };
|
|
694
|
+
const rel = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
|
|
695
|
+
if (rel === "" || rel.split("/").includes("..")) return { reason: "no-candidate" };
|
|
696
|
+
const normalizedRel = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
|
|
697
|
+
candidates = [];
|
|
698
|
+
for (const [normalized, paths] of index) {
|
|
699
|
+
if (normalized === normalizedRel || normalized.endsWith(`/${normalizedRel}`)) {
|
|
700
|
+
candidates.push(...paths);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (candidates.length === 0) return { reason: "no-candidate" };
|
|
572
704
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// a folder-internal symlink escaping the Service folder passes the lexical
|
|
578
|
-
// checks above but must not yield a URI the exact slot route would refuse.
|
|
579
|
-
let resolved: string;
|
|
580
|
-
let resolvedFolder: string;
|
|
705
|
+
// Safety gate (aligned with the read hit path, G5 defect closure): lexical
|
|
706
|
+
// containment, then realpath containment, then isFile.
|
|
707
|
+
const folderRoot = resolve(endpointFolder);
|
|
708
|
+
let realFolderRoot: string;
|
|
581
709
|
try {
|
|
582
|
-
|
|
583
|
-
resolvedFolder = realpathSync(endpointFolder);
|
|
710
|
+
realFolderRoot = realpathSync(folderRoot);
|
|
584
711
|
} catch {
|
|
585
|
-
return
|
|
712
|
+
return { reason: "no-candidate" };
|
|
586
713
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
714
|
+
const safe: string[] = [];
|
|
715
|
+
for (const candidate of candidates) {
|
|
716
|
+
const absolute = resolve(folderRoot, ...candidate.split("/"));
|
|
717
|
+
if (!isInsideRealRoot(realFolderRoot, absolute)) continue;
|
|
718
|
+
let realAbsolute: string;
|
|
719
|
+
try {
|
|
720
|
+
realAbsolute = realpathSync(absolute);
|
|
721
|
+
} catch {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (!isInsideRealRoot(realFolderRoot, realAbsolute)) continue;
|
|
725
|
+
try {
|
|
726
|
+
if (!statSync(realAbsolute).isFile()) continue;
|
|
727
|
+
} catch {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
safe.push(candidate);
|
|
593
731
|
}
|
|
594
|
-
if (
|
|
595
|
-
|
|
732
|
+
if (safe.length === 0) return { reason: "out-of-folder" };
|
|
733
|
+
const matches: string[] = [];
|
|
734
|
+
for (const candidate of safe) {
|
|
735
|
+
if (sha256Prefix6Of(resolve(folderRoot, ...candidate.split("/"))) === docid) matches.push(candidate);
|
|
736
|
+
}
|
|
737
|
+
if (matches.length === 1) return { relPath: matches[0] };
|
|
738
|
+
if (matches.length === 0) return { reason: "hash-mismatch" };
|
|
739
|
+
return { reason: "ambiguous" };
|
|
596
740
|
}
|
|
597
741
|
|
|
598
|
-
function
|
|
599
|
-
const relPath = endpointRelativePathOf(providerLocation, endpointFolder);
|
|
600
|
-
if (!relPath) return undefined;
|
|
742
|
+
function ukpUriFromRelPath(endpointName: string, relPath: string): string {
|
|
601
743
|
return `ukp://${endpointName}/${relPath.split("/").map(encodeUkpUriSegment).join("/")}`;
|
|
602
744
|
}
|
|
603
745
|
|
|
604
746
|
/**
|
|
605
|
-
* Map one QMD search result to a UKP read-ready reference (ADR 0011)
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* no usable docid has no UKP read route and is `provider_only`.
|
|
747
|
+
* Map one QMD search result to a UKP read-ready reference (ADR 0011) under
|
|
748
|
+
* the ADR 0025 verified-emission rule: the durable `ukp_uri` is emitted only
|
|
749
|
+
* when a candidate file's sha256 prefix matches the docid; otherwise the
|
|
750
|
+
* omission reason lands in the sidecar (REF-4). Name, title, and path stay
|
|
751
|
+
* display-only provenance (`provider_location`). A result with no usable
|
|
752
|
+
* docid has no UKP read route and no verifiable uri — `provider_only`.
|
|
612
753
|
*/
|
|
613
754
|
function mapQmdResultToReference(
|
|
614
755
|
endpointName: string,
|
|
615
756
|
endpointFolder: string,
|
|
616
757
|
result: unknown,
|
|
758
|
+
index: EndpointFileIndex,
|
|
617
759
|
): QmdReferenceMapping {
|
|
618
760
|
const providerLocation = providerLocationOf(result);
|
|
619
|
-
const ukpUri = ukpUriOf(providerLocation, endpointName, endpointFolder);
|
|
620
761
|
const docid = docidOf(result);
|
|
762
|
+
const route = providerLocation !== ""
|
|
763
|
+
? verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, index)
|
|
764
|
+
: {};
|
|
765
|
+
const ukpUri = route.relPath !== undefined ? ukpUriFromRelPath(endpointName, route.relPath) : undefined;
|
|
621
766
|
if (!docid) {
|
|
622
767
|
return {
|
|
623
768
|
provider_location: providerLocation,
|
|
624
769
|
endpoint: endpointName,
|
|
625
770
|
status: "provider_only",
|
|
626
|
-
...(
|
|
771
|
+
...(route.reason !== undefined ? { ukp_uri_omission_reason: route.reason } : {}),
|
|
627
772
|
reason: "qmd result has no usable docid",
|
|
628
773
|
};
|
|
629
774
|
}
|
|
@@ -634,7 +779,11 @@ function mapQmdResultToReference(
|
|
|
634
779
|
...maybeLine(lineOf(result)),
|
|
635
780
|
status: "read_ready",
|
|
636
781
|
read_adapter: "qmd",
|
|
637
|
-
...(ukpUri
|
|
782
|
+
...(ukpUri !== undefined
|
|
783
|
+
? { ukp_uri: ukpUri }
|
|
784
|
+
: route.reason !== undefined
|
|
785
|
+
? { ukp_uri_omission_reason: route.reason }
|
|
786
|
+
: {}),
|
|
638
787
|
};
|
|
639
788
|
}
|
|
640
789
|
|
|
@@ -684,6 +833,7 @@ function renderResultUnit(
|
|
|
684
833
|
endpointName: string,
|
|
685
834
|
endpointFolder: string,
|
|
686
835
|
result: unknown,
|
|
836
|
+
resolveIndex: () => EndpointFileIndex,
|
|
687
837
|
remoteUri?: string,
|
|
688
838
|
remoteEndpoint = false,
|
|
689
839
|
): string {
|
|
@@ -692,8 +842,13 @@ function renderResultUnit(
|
|
|
692
842
|
const line = lineOf(result);
|
|
693
843
|
// Remote endpoints carry the server-declared ukp_uri (RQ-06/RQ-09): it is
|
|
694
844
|
// the handoff key, so the read line addresses the URI directly and the
|
|
695
|
-
// local docid serves provenance only.
|
|
696
|
-
|
|
845
|
+
// local docid serves provenance only. Local emission is ADR 0025 verified:
|
|
846
|
+
// the uri line appears only when a candidate file's hash matches the docid.
|
|
847
|
+
const verified = remoteUri !== undefined || endpointFolder === ""
|
|
848
|
+
? {}
|
|
849
|
+
: verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, resolveIndex());
|
|
850
|
+
const ukpUri = remoteUri
|
|
851
|
+
?? (verified.relPath !== undefined ? ukpUriFromRelPath(endpointName, verified.relPath) : undefined);
|
|
697
852
|
const title = rawTitleOf(result);
|
|
698
853
|
const base = basenameOf(providerLocation);
|
|
699
854
|
const location = base ? (line ? `${base}:${line}` : base) : "";
|
|
@@ -744,8 +899,14 @@ function renderResultUnits(
|
|
|
744
899
|
}
|
|
745
900
|
if (!Array.isArray(nativeResults)) return null;
|
|
746
901
|
if (nativeResults.length === 0) return "(no matches)";
|
|
902
|
+
// One endpoint-file index per endpoint render (ADR 0025): built lazily so
|
|
903
|
+
// empty result sets and pure-remote endpoints pay no walk.
|
|
904
|
+
let index: EndpointFileIndex | undefined;
|
|
905
|
+
const resolveIndex = (): EndpointFileIndex => index ??= buildEndpointFileIndex(endpointFolder);
|
|
747
906
|
return nativeResults
|
|
748
|
-
.map((result,
|
|
907
|
+
.map((result, index_) =>
|
|
908
|
+
renderResultUnit(index_ + 1, endpointName, endpointFolder, result, resolveIndex, remoteUris?.[index_], remoteUris !== undefined)
|
|
909
|
+
)
|
|
749
910
|
.join("\n\n");
|
|
750
911
|
}
|
|
751
912
|
|
|
@@ -790,13 +951,15 @@ function buildQmdReferenceSidecar(
|
|
|
790
951
|
if (!Array.isArray(nativeResults)) {
|
|
791
952
|
throw new Error("qmd-json output is not an array");
|
|
792
953
|
}
|
|
954
|
+
// One endpoint-file index per sidecar build (ADR 0025).
|
|
955
|
+
const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
|
|
793
956
|
return {
|
|
794
957
|
schema: "ukp.search.references.v1",
|
|
795
958
|
endpoint: endpointName,
|
|
796
959
|
source_artifact: sourceArtifact,
|
|
797
|
-
results: nativeResults.map((result,
|
|
798
|
-
index,
|
|
799
|
-
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
960
|
+
results: nativeResults.map((result, index_) => ({
|
|
961
|
+
index: index_,
|
|
962
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result, index),
|
|
800
963
|
})),
|
|
801
964
|
};
|
|
802
965
|
}
|
|
@@ -837,12 +1000,15 @@ export function buildInlineReferences(
|
|
|
837
1000
|
return undefined;
|
|
838
1001
|
}
|
|
839
1002
|
if (!Array.isArray(nativeResults)) return undefined;
|
|
1003
|
+
// One endpoint-file index per inline build (ADR 0025) — same verified
|
|
1004
|
+
// mapping as the sidecar, so remote inline references stay in lockstep.
|
|
1005
|
+
const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
|
|
840
1006
|
return {
|
|
841
1007
|
schema: "ukp.search.references.v1",
|
|
842
1008
|
endpoint: endpointName,
|
|
843
|
-
results: nativeResults.map((result,
|
|
844
|
-
index,
|
|
845
|
-
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
1009
|
+
results: nativeResults.map((result, index_) => ({
|
|
1010
|
+
index: index_,
|
|
1011
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result, index),
|
|
846
1012
|
})),
|
|
847
1013
|
};
|
|
848
1014
|
}
|
|
@@ -18,10 +18,15 @@ Fast path for a simple knowledge folder:
|
|
|
18
18
|
1. Initialize QMD (qmd init)
|
|
19
19
|
2. Add the searchable collections (qmd collection add)
|
|
20
20
|
For a pure knowledge base, add the folder root; otherwise add the folder(s)
|
|
21
|
-
whose content should be searchable.
|
|
21
|
+
whose content should be searchable. Check what already exists first
|
|
22
|
+
(qmd collection list): re-adding an already-indexed folder creates a
|
|
23
|
+
duplicate collection, and add does not warn about it.
|
|
22
24
|
3. Use a stable short collection name (qmd collection rename)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
By default add registers the collection under the folder's full path as
|
|
26
|
+
its name; the short name comes from this rename, never from add itself.
|
|
27
|
+
rename matches the exact current name only, so re-check collection list
|
|
28
|
+
before renaming — the short name you had in mind may already belong to an
|
|
29
|
+
existing collection.
|
|
25
30
|
4. Update the index (qmd update, qmd status)
|
|
26
31
|
5. Verify the provider before returning to UKP (qmd search / qmd get)
|
|
27
32
|
If a search returns matches, the provider is ready; return to
|
package/src/commands/init.ts
CHANGED
|
@@ -193,6 +193,13 @@ function executeInitServiceCommand(args: readonly string[], context: InitCommand
|
|
|
193
193
|
`location: ${result.folder}`,
|
|
194
194
|
"capability: search",
|
|
195
195
|
"provider: qmd",
|
|
196
|
+
// Folder-existence probe only (no provider state read): an existing
|
|
197
|
+
// provider index means collections may already be registered — route
|
|
198
|
+
// to the provider-scoped topic instead of naming provider commands
|
|
199
|
+
// (provider-agnostic stdout, same pattern as diagnose's hint).
|
|
200
|
+
...(existsSync(join(result.folder, ".qmd"))
|
|
201
|
+
? ["note: existing provider index detected (.qmd/); align collection naming before adding new ones (see 'ukp guide service qmd')"]
|
|
202
|
+
: []),
|
|
196
203
|
"next: ukp guide service qmd",
|
|
197
204
|
"next: ukp diagnose",
|
|
198
205
|
"next: ukp register",
|
|
@@ -159,6 +159,10 @@ export function executeRegisterCommand(
|
|
|
159
159
|
`registered: ${report.service.effectiveName}`,
|
|
160
160
|
...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
|
|
161
161
|
`location: ${report.service.folder}`,
|
|
162
|
+
// Register never edits Client Config (guide client owns that); this is
|
|
163
|
+
// a pointer for the cross-workspace consumption case, which is the
|
|
164
|
+
// standard follow-up after registering a Service others will reference.
|
|
165
|
+
"next: to use this endpoint by default from another workspace, see 'ukp guide client' (workspace .ukp/client.toml)",
|
|
162
166
|
];
|
|
163
167
|
return {
|
|
164
168
|
exitCode: 0,
|