dsh-reference-anything 0.2.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/LICENSE +22 -0
- package/NOTICE.md +54 -0
- package/README.md +228 -0
- package/cordis.patch.yml +63 -0
- package/images/logo.png +0 -0
- package/lib/client/index.d.ts +3 -0
- package/lib/client.js +18151 -0
- package/lib/contract.d.ts +300 -0
- package/lib/contract.js +102 -0
- package/lib/errors.d.ts +56 -0
- package/lib/errors.js +21 -0
- package/lib/host.d.ts +102 -0
- package/lib/host.js +55 -0
- package/lib/index.d.ts +83 -0
- package/lib/index.js +153 -0
- package/lib/opencli.d.ts +116 -0
- package/lib/opencli.js +356 -0
- package/lib/reference.d.ts +49 -0
- package/lib/reference.js +228 -0
- package/lib/render.d.ts +101 -0
- package/lib/render.js +250 -0
- package/lib/retain.d.ts +35 -0
- package/lib/retain.js +116 -0
- package/lib/search.d.ts +75 -0
- package/lib/search.js +126 -0
- package/lib/serialize.d.ts +22 -0
- package/lib/serialize.js +24 -0
- package/lib/sources/file.d.ts +100 -0
- package/lib/sources/file.js +236 -0
- package/lib/sources/web-chat/index.d.ts +114 -0
- package/lib/sources/web-chat/index.js +359 -0
- package/lib/store/spec.d.ts +312 -0
- package/lib/store/spec.js +94 -0
- package/lib/store/store.d.ts +189 -0
- package/lib/store/store.js +638 -0
- package/lib/sync/index.d.ts +91 -0
- package/lib/sync/index.js +425 -0
- package/lib/tool.d.ts +62 -0
- package/lib/tool.js +364 -0
- package/lib/typert.d.ts +2 -0
- package/lib/typert.js +34 -0
- package/lib/types.d.ts +215 -0
- package/lib/types.js +12 -0
- package/lib/uri-codec.d.ts +5 -0
- package/lib/uri-codec.js +39 -0
- package/lib/uri.d.ts +59 -0
- package/lib/uri.js +86 -0
- package/lib/web.d.ts +4 -0
- package/lib/web.js +11 -0
- package/lib/window.d.ts +23 -0
- package/lib/window.js +35 -0
- package/lib/wire.d.ts +117 -0
- package/lib/wire.js +49 -0
- package/lib/workspace.d.ts +18 -0
- package/lib/workspace.js +81 -0
- package/opencli-plugin/chatgpt.js +125 -0
- package/opencli-plugin/claude.js +134 -0
- package/opencli-plugin/common.js +307 -0
- package/opencli-plugin/deepseek.js +152 -0
- package/opencli-plugin/gemini.js +165 -0
- package/opencli-plugin/grok.js +123 -0
- package/opencli-plugin/kimi.js +123 -0
- package/opencli-plugin/opencli-plugin.json +6 -0
- package/opencli-plugin/package.json +8 -0
- package/opencli-plugin/since-guard.js +45 -0
- package/package.json +180 -0
package/lib/search.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query parsing and relevance scoring for conversation discovery. Pure — no
|
|
3
|
+
* Node builtins, no cordis — because both the Host source and the browser
|
|
4
|
+
* bundle import it.
|
|
5
|
+
*
|
|
6
|
+
* The `@` mention path constrains this more than it looks: the trigger token
|
|
7
|
+
* ends at the first whitespace (ui-input-trigger `core/detect.ts`), so a
|
|
8
|
+
* mention query is always a single space-free run of characters. That rules
|
|
9
|
+
* out `@chatgpt cache` and it rules out multi-word title search, which is why
|
|
10
|
+
* the provider separator is `:`/`/` and why matching falls back to a
|
|
11
|
+
* subsequence pass rather than demanding a contiguous substring.
|
|
12
|
+
*/
|
|
13
|
+
/** Spellings accepted in a provider prefix, including short forms worth typing. */
|
|
14
|
+
const PROVIDER_ALIASES = {
|
|
15
|
+
chatgpt: 'chatgpt', gpt: 'chatgpt', openai: 'chatgpt',
|
|
16
|
+
claude: 'claude',
|
|
17
|
+
gemini: 'gemini',
|
|
18
|
+
deepseek: 'deepseek', ds: 'deepseek',
|
|
19
|
+
grok: 'grok',
|
|
20
|
+
kimi: 'kimi',
|
|
21
|
+
};
|
|
22
|
+
/** Provider prefix followed by a separator that survives `@` token detection, or by nothing. */
|
|
23
|
+
const PROVIDER_PREFIX = /^@?([A-Za-z]+)(?:[:/\s]+([\s\S]*))?$/u;
|
|
24
|
+
/**
|
|
25
|
+
* Split a leading provider scope off a query.
|
|
26
|
+
*
|
|
27
|
+
* `:` and `/` are the separators that survive `@` token detection; whitespace
|
|
28
|
+
* is accepted too because the model-facing `reference_list` path is not bound
|
|
29
|
+
* by the token rules. A bare provider name scopes to that provider with an
|
|
30
|
+
* empty query — `@claude` is the fast "show me my Claude chats" gesture.
|
|
31
|
+
* @param value - raw query, with or without a provider prefix.
|
|
32
|
+
* @returns the recognized provider (if any) and the remaining query.
|
|
33
|
+
*/
|
|
34
|
+
export function parseProviderQuery(value) {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
const match = PROVIDER_PREFIX.exec(trimmed);
|
|
37
|
+
if (!match)
|
|
38
|
+
return { query: trimmed };
|
|
39
|
+
const provider = PROVIDER_ALIASES[match[1].toLocaleLowerCase()];
|
|
40
|
+
if (!provider)
|
|
41
|
+
return { query: trimmed };
|
|
42
|
+
return { provider, query: (match[2] ?? '').trim() };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Match quality bands, best last. Ordered, and a band always outranks every
|
|
46
|
+
* weaker one — within-band tiebreakers never cross a band boundary.
|
|
47
|
+
*/
|
|
48
|
+
export const MATCH_TIER = {
|
|
49
|
+
subsequence: 1,
|
|
50
|
+
substring: 2,
|
|
51
|
+
wordBoundary: 3,
|
|
52
|
+
prefix: 4,
|
|
53
|
+
exact: 5,
|
|
54
|
+
};
|
|
55
|
+
/** Separators a user might type that should not affect matching. */
|
|
56
|
+
const LOOSE = /[\s\-_.]/u;
|
|
57
|
+
const LOOSE_ALL = /[\s\-_.]+/gu;
|
|
58
|
+
/**
|
|
59
|
+
* Score one title against a needle.
|
|
60
|
+
*
|
|
61
|
+
* The subsequence band is what makes a space-free query usable: `cachedes`
|
|
62
|
+
* and `cache-design` both reach "Cache design notes", which no substring
|
|
63
|
+
* search can do once the token cannot hold a space.
|
|
64
|
+
* @param title - conversation title.
|
|
65
|
+
* @param needle - user query, already stripped of any provider prefix.
|
|
66
|
+
* @returns the match, or undefined when the title does not match at all.
|
|
67
|
+
*/
|
|
68
|
+
export function scoreTitle(title, needle) {
|
|
69
|
+
const query = needle.trim().toLocaleLowerCase();
|
|
70
|
+
if (query === '')
|
|
71
|
+
return undefined;
|
|
72
|
+
const haystack = title.toLocaleLowerCase();
|
|
73
|
+
if (haystack === query)
|
|
74
|
+
return { tier: MATCH_TIER.exact, at: 0, span: query.length };
|
|
75
|
+
const at = haystack.indexOf(query);
|
|
76
|
+
if (at === 0)
|
|
77
|
+
return { tier: MATCH_TIER.prefix, at, span: query.length };
|
|
78
|
+
if (at > 0) {
|
|
79
|
+
const tier = LOOSE.test(haystack.charAt(at - 1)) ? MATCH_TIER.wordBoundary : MATCH_TIER.substring;
|
|
80
|
+
return { tier, at, span: query.length };
|
|
81
|
+
}
|
|
82
|
+
return subsequence(haystack, query.replace(LOOSE_ALL, ''));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Locate `query`'s characters in order, not necessarily adjacent.
|
|
86
|
+
* @returns the match spanning the earliest greedy run, or undefined.
|
|
87
|
+
*/
|
|
88
|
+
function subsequence(haystack, query) {
|
|
89
|
+
if (query === '')
|
|
90
|
+
return undefined;
|
|
91
|
+
let first = -1;
|
|
92
|
+
let cursor = 0;
|
|
93
|
+
for (const char of query) {
|
|
94
|
+
const found = haystack.indexOf(char, cursor);
|
|
95
|
+
if (found < 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
if (first < 0)
|
|
98
|
+
first = found;
|
|
99
|
+
cursor = found + char.length;
|
|
100
|
+
}
|
|
101
|
+
return { tier: MATCH_TIER.subsequence, at: first, span: cursor - first };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Order two matches best-first. Band dominates; within a band a tighter,
|
|
105
|
+
* earlier match wins. Callers break remaining ties on recency.
|
|
106
|
+
* @returns negative when `a` should sort before `b`.
|
|
107
|
+
*/
|
|
108
|
+
export function compareMatches(a, b) {
|
|
109
|
+
return (b.tier - a.tier) || (a.span - b.span) || (a.at - b.at);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Excerpt the text around a hit for display beside a search result.
|
|
113
|
+
*
|
|
114
|
+
* UI only: this is mirrored conversation content, and it must not reach the
|
|
115
|
+
* model outside the untrusted-data envelope `reference_read` builds.
|
|
116
|
+
* @param text - the full text the hit was found in.
|
|
117
|
+
* @param at - offset of the hit.
|
|
118
|
+
* @param radius - characters of context to keep on each side.
|
|
119
|
+
* @returns a whitespace-collapsed excerpt, elided where it was cut.
|
|
120
|
+
*/
|
|
121
|
+
export function snippet(text, at, radius = 30) {
|
|
122
|
+
const start = Math.max(0, at - radius);
|
|
123
|
+
const end = Math.min(text.length, at + radius);
|
|
124
|
+
const body = text.slice(start, end).replace(/\s+/gu, ' ').trim();
|
|
125
|
+
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-safe JSON for text that is framed inside pseudo-XML tags.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-reference-anything/serialize
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Serialize a value as JSON in which no `<` survives literally.
|
|
8
|
+
*
|
|
9
|
+
* Referenced material is untrusted text placed inside a
|
|
10
|
+
* `<referenced-conversations>` frame. Without this, a conversation whose
|
|
11
|
+
* content spells that frame's closing tag would appear to end the data region,
|
|
12
|
+
* and everything after it would read to the model as the harness speaking
|
|
13
|
+
* rather than as quoted material.
|
|
14
|
+
*
|
|
15
|
+
* Each one becomes its six-character JSON unicode escape instead. That escape
|
|
16
|
+
* is valid only inside a JSON string, and a literal `<` only ever occurs
|
|
17
|
+
* inside string literals in `JSON.stringify` output, so the substitution is
|
|
18
|
+
* lossless: parsing the result yields the original value exactly.
|
|
19
|
+
* @param value - any JSON-serializable value.
|
|
20
|
+
* @returns indented JSON carrying no literal `<`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function stringifyTagSafeJson(value: unknown): string;
|
package/lib/serialize.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-safe JSON for text that is framed inside pseudo-XML tags.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-reference-anything/serialize
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Serialize a value as JSON in which no `<` survives literally.
|
|
8
|
+
*
|
|
9
|
+
* Referenced material is untrusted text placed inside a
|
|
10
|
+
* `<referenced-conversations>` frame. Without this, a conversation whose
|
|
11
|
+
* content spells that frame's closing tag would appear to end the data region,
|
|
12
|
+
* and everything after it would read to the model as the harness speaking
|
|
13
|
+
* rather than as quoted material.
|
|
14
|
+
*
|
|
15
|
+
* Each one becomes its six-character JSON unicode escape instead. That escape
|
|
16
|
+
* is valid only inside a JSON string, and a literal `<` only ever occurs
|
|
17
|
+
* inside string literals in `JSON.stringify` output, so the substitution is
|
|
18
|
+
* lossless: parsing the result yields the original value exactly.
|
|
19
|
+
* @param value - any JSON-serializable value.
|
|
20
|
+
* @returns indented JSON carrying no literal `<`.
|
|
21
|
+
*/
|
|
22
|
+
export function stringifyTagSafeJson(value) {
|
|
23
|
+
return JSON.stringify(value, null, 2).replace(/</gu, '\\u003c');
|
|
24
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference source over exported conversations stored on disk.
|
|
3
|
+
*
|
|
4
|
+
* Every chat product can export a conversation, and an exported file is the
|
|
5
|
+
* one form of outside material that needs no browser, no credentials, and no
|
|
6
|
+
* network — which also makes it the source that end-to-end tests can drive.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-reference-anything/file
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import z from '@deepseek-ai/schemastery';
|
|
12
|
+
import type { ConversationItem, ReferenceRef, ReferenceSnapshot, ReferenceSource, ReferenceSummary, ReferenceWindow } from '../types.ts';
|
|
13
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
14
|
+
export declare const name = "reference-file";
|
|
15
|
+
/** The registry this source registers into. */
|
|
16
|
+
export declare const inject: string[];
|
|
17
|
+
/** Registry id, and the `source` half of every reference this source owns. */
|
|
18
|
+
export declare const FILE_SOURCE_ID = "file";
|
|
19
|
+
/** Deployment settings for the file source. */
|
|
20
|
+
export interface Config {
|
|
21
|
+
/**
|
|
22
|
+
* Directories holding exported conversations. Required and never defaulted:
|
|
23
|
+
* a reference source that reads an unbounded part of the filesystem by
|
|
24
|
+
* default is not a safe thing to mount.
|
|
25
|
+
*/
|
|
26
|
+
roots?: string[];
|
|
27
|
+
/** File extensions treated as exports. */
|
|
28
|
+
extensions?: string[];
|
|
29
|
+
}
|
|
30
|
+
/** Runtime schema for {@link Config}. */
|
|
31
|
+
export declare const Config: z<Config>;
|
|
32
|
+
/**
|
|
33
|
+
* On-disk export document.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately smaller than {@link ReferenceSnapshot}: this is a format people
|
|
36
|
+
* and export scripts write by hand, so it carries only what cannot be derived.
|
|
37
|
+
*/
|
|
38
|
+
interface ExportDocument {
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
readonly origin?: string;
|
|
41
|
+
readonly updatedAt?: number;
|
|
42
|
+
readonly messages: readonly ConversationItem[];
|
|
43
|
+
}
|
|
44
|
+
/** Reads exported conversations out of a fixed set of directories. */
|
|
45
|
+
export declare class FileReferenceSource implements ReferenceSource {
|
|
46
|
+
private readonly roots;
|
|
47
|
+
private readonly extensions;
|
|
48
|
+
readonly id = "file";
|
|
49
|
+
/**
|
|
50
|
+
* @param roots - absolute or process-relative directories to search.
|
|
51
|
+
* @param extensions - file extensions treated as exports.
|
|
52
|
+
*/
|
|
53
|
+
constructor(roots: readonly string[], extensions: readonly string[]);
|
|
54
|
+
/** @returns whether at least one configured root is a readable directory. */
|
|
55
|
+
available(): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* List exports whose label, id, or filename contains `query`.
|
|
58
|
+
* @param query - case-insensitive substring; empty matches everything.
|
|
59
|
+
* @param limit - maximum items to return.
|
|
60
|
+
* @param signal - cancellation from the caller.
|
|
61
|
+
* @returns matching exports, at most `limit`.
|
|
62
|
+
*/
|
|
63
|
+
list(query: string, limit: number, signal?: AbortSignal): Promise<ReferenceSummary[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Read one window of turns from an export.
|
|
66
|
+
* @param ref - a reference this source owns; `ref.id` is a root-relative path.
|
|
67
|
+
* @param window - which turns to return.
|
|
68
|
+
* @param signal - cancellation from the caller.
|
|
69
|
+
* @returns the requested turns and their position in the conversation.
|
|
70
|
+
*/
|
|
71
|
+
read(ref: ReferenceRef, window: ReferenceWindow, signal?: AbortSignal): Promise<ReferenceSnapshot>;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a root-relative id to a real path inside a configured root.
|
|
74
|
+
*
|
|
75
|
+
* The check runs against the resolved real path, so neither `..` segments
|
|
76
|
+
* nor a symlink pointing outside a root can reach a file the deployment did
|
|
77
|
+
* not offer.
|
|
78
|
+
*/
|
|
79
|
+
private locate;
|
|
80
|
+
private walk;
|
|
81
|
+
private summarize;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Register the file reference source.
|
|
85
|
+
* @param ctx - the mounting context.
|
|
86
|
+
* @param config - validated deployment settings.
|
|
87
|
+
*/
|
|
88
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
89
|
+
/**
|
|
90
|
+
* Validate one export document.
|
|
91
|
+
*
|
|
92
|
+
* A file is an untrusted durable boundary — hand-written, produced by an
|
|
93
|
+
* exporter this package does not control, possibly written by an older
|
|
94
|
+
* version — so its shape is checked rather than assumed.
|
|
95
|
+
* @param raw - the file's UTF-8 contents.
|
|
96
|
+
* @param path - the path, for the diagnostic.
|
|
97
|
+
* @returns the validated document.
|
|
98
|
+
*/
|
|
99
|
+
export declare function parseExport(raw: string, path: string): ExportDocument;
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference source over exported conversations stored on disk.
|
|
3
|
+
*
|
|
4
|
+
* Every chat product can export a conversation, and an exported file is the
|
|
5
|
+
* one form of outside material that needs no browser, no credentials, and no
|
|
6
|
+
* network — which also makes it the source that end-to-end tests can drive.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-reference-anything/file
|
|
9
|
+
*/
|
|
10
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
11
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import z from '@deepseek-ai/schemastery';
|
|
13
|
+
import { ReferenceAnythingError } from "../errors.js";
|
|
14
|
+
import { sliceTurns } from "../window.js";
|
|
15
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
16
|
+
export const name = 'reference-file';
|
|
17
|
+
/** The registry this source registers into. */
|
|
18
|
+
export const inject = ['references'];
|
|
19
|
+
/** Registry id, and the `source` half of every reference this source owns. */
|
|
20
|
+
export const FILE_SOURCE_ID = 'file';
|
|
21
|
+
/** Directory depth walked below each root. */
|
|
22
|
+
const MAX_DEPTH = 4;
|
|
23
|
+
/** Runtime schema for {@link Config}. */
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
roots: z.array(z.string()).default([]),
|
|
26
|
+
extensions: z.array(z.string()).default(['.json']),
|
|
27
|
+
});
|
|
28
|
+
/** Reads exported conversations out of a fixed set of directories. */
|
|
29
|
+
export class FileReferenceSource {
|
|
30
|
+
roots;
|
|
31
|
+
extensions;
|
|
32
|
+
id = FILE_SOURCE_ID;
|
|
33
|
+
/**
|
|
34
|
+
* @param roots - absolute or process-relative directories to search.
|
|
35
|
+
* @param extensions - file extensions treated as exports.
|
|
36
|
+
*/
|
|
37
|
+
constructor(roots, extensions) {
|
|
38
|
+
this.roots = roots;
|
|
39
|
+
this.extensions = extensions;
|
|
40
|
+
}
|
|
41
|
+
/** @returns whether at least one configured root is a readable directory. */
|
|
42
|
+
async available() {
|
|
43
|
+
const checks = await Promise.all(this.roots.map(async (root) => {
|
|
44
|
+
try {
|
|
45
|
+
return (await stat(root)).isDirectory();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A root that does not exist yet is an ordinary state (the user has
|
|
49
|
+
// not exported anything), not a misconfiguration to fail on.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
return checks.includes(true);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* List exports whose label, id, or filename contains `query`.
|
|
57
|
+
* @param query - case-insensitive substring; empty matches everything.
|
|
58
|
+
* @param limit - maximum items to return.
|
|
59
|
+
* @param signal - cancellation from the caller.
|
|
60
|
+
* @returns matching exports, at most `limit`.
|
|
61
|
+
*/
|
|
62
|
+
async list(query, limit, signal) {
|
|
63
|
+
const needle = query.toLocaleLowerCase();
|
|
64
|
+
const found = [];
|
|
65
|
+
for (const root of this.roots) {
|
|
66
|
+
for (const path of await this.walk(root, root, 0, signal)) {
|
|
67
|
+
if (found.length >= limit)
|
|
68
|
+
return found;
|
|
69
|
+
const summary = await this.summarize(root, path);
|
|
70
|
+
if (summary === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
const haystack = `${summary.label} ${summary.ref.id}`.toLocaleLowerCase();
|
|
73
|
+
if (needle !== '' && !haystack.includes(needle))
|
|
74
|
+
continue;
|
|
75
|
+
found.push(summary);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return found.slice(0, limit);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read one window of turns from an export.
|
|
82
|
+
* @param ref - a reference this source owns; `ref.id` is a root-relative path.
|
|
83
|
+
* @param window - which turns to return.
|
|
84
|
+
* @param signal - cancellation from the caller.
|
|
85
|
+
* @returns the requested turns and their position in the conversation.
|
|
86
|
+
*/
|
|
87
|
+
async read(ref, window, signal) {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
const path = await this.locate(ref.id);
|
|
90
|
+
const document = parseExport(await readFile(path, 'utf8'), path);
|
|
91
|
+
const slice = sliceTurns(document.messages, window);
|
|
92
|
+
return {
|
|
93
|
+
ref,
|
|
94
|
+
label: labelOf(document, ref.id),
|
|
95
|
+
...document.origin === undefined ? {} : { origin: document.origin },
|
|
96
|
+
...document.updatedAt === undefined ? {} : { updatedAt: document.updatedAt },
|
|
97
|
+
body: slice,
|
|
98
|
+
// The whole file is read every time, so nothing is beyond this source's reach.
|
|
99
|
+
partial: false,
|
|
100
|
+
capturedAt: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a root-relative id to a real path inside a configured root.
|
|
105
|
+
*
|
|
106
|
+
* The check runs against the resolved real path, so neither `..` segments
|
|
107
|
+
* nor a symlink pointing outside a root can reach a file the deployment did
|
|
108
|
+
* not offer.
|
|
109
|
+
*/
|
|
110
|
+
async locate(id) {
|
|
111
|
+
for (const root of this.roots) {
|
|
112
|
+
const candidate = resolve(root, id);
|
|
113
|
+
let real;
|
|
114
|
+
let realRoot;
|
|
115
|
+
try {
|
|
116
|
+
real = await realpath(candidate);
|
|
117
|
+
realRoot = await realpath(root);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (real !== realRoot && !real.startsWith(realRoot.endsWith(sep) ? realRoot : `${realRoot}${sep}`)) {
|
|
123
|
+
throw new ReferenceAnythingError(`exported conversation ${JSON.stringify(id)} resolves outside its configured root`, 'REFERENCE_NOT_FOUND');
|
|
124
|
+
}
|
|
125
|
+
return real;
|
|
126
|
+
}
|
|
127
|
+
throw new ReferenceAnythingError(`no exported conversation named ${JSON.stringify(id)} exists under the configured roots`, 'REFERENCE_NOT_FOUND');
|
|
128
|
+
}
|
|
129
|
+
async walk(root, dir, depth, signal) {
|
|
130
|
+
signal?.throwIfAborted();
|
|
131
|
+
if (depth > MAX_DEPTH)
|
|
132
|
+
return [];
|
|
133
|
+
// An unreadable directory is one the user cannot reference from anyway;
|
|
134
|
+
// the other roots still list.
|
|
135
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
136
|
+
const paths = [];
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const path = join(dir, entry.name);
|
|
139
|
+
if (entry.isDirectory()) {
|
|
140
|
+
paths.push(...await this.walk(root, path, depth + 1, signal));
|
|
141
|
+
}
|
|
142
|
+
else if (this.extensions.includes(extname(entry.name))) {
|
|
143
|
+
paths.push(path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return paths;
|
|
147
|
+
}
|
|
148
|
+
async summarize(root, path) {
|
|
149
|
+
const id = relative(root, path);
|
|
150
|
+
let document;
|
|
151
|
+
try {
|
|
152
|
+
document = parseExport(await readFile(path, 'utf8'), path);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Discovery skips a malformed file so one bad export does not hide the
|
|
156
|
+
// rest; naming it by reference still fails loudly through read().
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ref: { source: FILE_SOURCE_ID, id },
|
|
161
|
+
label: labelOf(document, id),
|
|
162
|
+
origin: path,
|
|
163
|
+
...document.updatedAt === undefined ? {} : { updatedAt: document.updatedAt },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Register the file reference source.
|
|
169
|
+
* @param ctx - the mounting context.
|
|
170
|
+
* @param config - validated deployment settings.
|
|
171
|
+
*/
|
|
172
|
+
export function apply(ctx, config = {}) {
|
|
173
|
+
const roots = config.roots ?? [];
|
|
174
|
+
const extensions = config.extensions ?? ['.json'];
|
|
175
|
+
if (roots.length === 0) {
|
|
176
|
+
throw new ReferenceAnythingError('reference-file needs at least one directory in "roots"; mounting it with none can never resolve a reference', 'REFERENCE_INVALID_CONFIG');
|
|
177
|
+
}
|
|
178
|
+
const source = new FileReferenceSource(roots, extensions);
|
|
179
|
+
ctx.effect(() => ctx.references.registerSource(source), 'reference-file.registerSource()');
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Validate one export document.
|
|
183
|
+
*
|
|
184
|
+
* A file is an untrusted durable boundary — hand-written, produced by an
|
|
185
|
+
* exporter this package does not control, possibly written by an older
|
|
186
|
+
* version — so its shape is checked rather than assumed.
|
|
187
|
+
* @param raw - the file's UTF-8 contents.
|
|
188
|
+
* @param path - the path, for the diagnostic.
|
|
189
|
+
* @returns the validated document.
|
|
190
|
+
*/
|
|
191
|
+
export function parseExport(raw, path) {
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(raw);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw invalid(path, 'is not valid JSON', error);
|
|
198
|
+
}
|
|
199
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
200
|
+
throw invalid(path, 'is not a JSON object');
|
|
201
|
+
}
|
|
202
|
+
const record = parsed;
|
|
203
|
+
const { label, origin, updatedAt, messages } = record;
|
|
204
|
+
if (!Array.isArray(messages))
|
|
205
|
+
throw invalid(path, 'has no "messages" array');
|
|
206
|
+
const items = messages.map((entry, index) => {
|
|
207
|
+
if (typeof entry !== 'object' || entry === null)
|
|
208
|
+
throw invalid(path, `message ${index} is not an object`);
|
|
209
|
+
const { role, text } = entry;
|
|
210
|
+
if (role !== 'user' && role !== 'assistant') {
|
|
211
|
+
throw invalid(path, `message ${index} has role ${JSON.stringify(role)}, expected "user" or "assistant"`);
|
|
212
|
+
}
|
|
213
|
+
if (typeof text !== 'string')
|
|
214
|
+
throw invalid(path, `message ${index} has no string "text"`);
|
|
215
|
+
return { role, text };
|
|
216
|
+
});
|
|
217
|
+
if (label !== undefined && typeof label !== 'string')
|
|
218
|
+
throw invalid(path, 'has a non-string "label"');
|
|
219
|
+
if (origin !== undefined && typeof origin !== 'string')
|
|
220
|
+
throw invalid(path, 'has a non-string "origin"');
|
|
221
|
+
if (updatedAt !== undefined && typeof updatedAt !== 'number')
|
|
222
|
+
throw invalid(path, 'has a non-numeric "updatedAt"');
|
|
223
|
+
return {
|
|
224
|
+
...label === undefined ? {} : { label },
|
|
225
|
+
...origin === undefined ? {} : { origin },
|
|
226
|
+
...updatedAt === undefined ? {} : { updatedAt },
|
|
227
|
+
messages: items,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function labelOf(document, id) {
|
|
231
|
+
const label = document.label?.trim() ?? '';
|
|
232
|
+
return label === '' ? id : label;
|
|
233
|
+
}
|
|
234
|
+
function invalid(path, detail, cause) {
|
|
235
|
+
return new ReferenceAnythingError(`exported conversation ${JSON.stringify(path)} ${detail}`, 'REFERENCE_READ_FAILED', cause === undefined ? undefined : { cause });
|
|
236
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import type { ConversationAttachment, ReferenceSnapshot, ReferenceSource, ReferenceSummary, ReferenceWindow } from '../../types.ts';
|
|
4
|
+
import type { ChatProvider, SettingsRecord } from '../../store/spec.ts';
|
|
5
|
+
import { ConversationStore, type MatchedVia } from '../../store/store.ts';
|
|
6
|
+
import { ConversationSyncManager } from '../../sync/index.ts';
|
|
7
|
+
import { type OpenCliDiscovery } from '../../opencli.ts';
|
|
8
|
+
import { parseProviderQuery } from '../../search.ts';
|
|
9
|
+
export { parseProviderQuery };
|
|
10
|
+
export declare const name = "reference-web-chat";
|
|
11
|
+
export declare const inject: string[];
|
|
12
|
+
export interface Config {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
maxStdoutBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const Config: z<Config>;
|
|
17
|
+
declare module '@deepseek-ai/cordis' {
|
|
18
|
+
interface Context {
|
|
19
|
+
referenceChatHistory: WebChatHistoryService;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The stored facts about one mirrored conversation, shared by both list surfaces. */
|
|
23
|
+
export interface ConversationRow {
|
|
24
|
+
uriId: string;
|
|
25
|
+
provider: ChatProvider;
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
turnCount: number;
|
|
30
|
+
partial: boolean;
|
|
31
|
+
syncedAt: string;
|
|
32
|
+
}
|
|
33
|
+
/** One ranked hit from the mention/search surface. */
|
|
34
|
+
export interface ConversationSearchResult extends ConversationRow {
|
|
35
|
+
/** How this row was found, so the mention menu can say why it matched. */
|
|
36
|
+
matchedVia: MatchedVia;
|
|
37
|
+
/** Excerpt around a body hit. UI-only — never part of what the model reads. */
|
|
38
|
+
snippet?: string;
|
|
39
|
+
}
|
|
40
|
+
/** One row in the management list — unranked, but carrying whether the provider still lists it. */
|
|
41
|
+
export interface ManagedConversation extends ConversationRow {
|
|
42
|
+
remoteMissing: boolean;
|
|
43
|
+
}
|
|
44
|
+
export type { ProviderSyncState } from '../../store/store.ts';
|
|
45
|
+
export default class WebChatHistoryService extends Service implements ReferenceSource {
|
|
46
|
+
private readonly config;
|
|
47
|
+
static inject: string[];
|
|
48
|
+
static Config: z<Config>;
|
|
49
|
+
readonly id = "web-chat";
|
|
50
|
+
private storeValue?;
|
|
51
|
+
private syncValue?;
|
|
52
|
+
private autoSyncInterval?;
|
|
53
|
+
private autoSyncCatchUp?;
|
|
54
|
+
private readonly liveAttachments;
|
|
55
|
+
constructor(ctx: Context, config?: Config);
|
|
56
|
+
protected [Service.init](): Promise<void>;
|
|
57
|
+
get store(): ConversationStore;
|
|
58
|
+
get sync(): ConversationSyncManager;
|
|
59
|
+
available(): Promise<boolean>;
|
|
60
|
+
list(query: string, limit: number): Promise<ReferenceSummary[]>;
|
|
61
|
+
read(ref: {
|
|
62
|
+
source: string;
|
|
63
|
+
id: string;
|
|
64
|
+
}, window: ReferenceWindow, signal?: AbortSignal): Promise<ReferenceSnapshot>;
|
|
65
|
+
/** Resolve an attachment locator captured by a recent metadata-only read. Never persisted. */
|
|
66
|
+
liveAttachment(conversationKey: string, attachmentId: string): (ConversationAttachment & {
|
|
67
|
+
locator?: string;
|
|
68
|
+
}) | undefined;
|
|
69
|
+
private readRemote;
|
|
70
|
+
search(query: string, provider: ChatProvider | undefined, limit: number, signal?: AbortSignal): Promise<ConversationSearchResult[]>;
|
|
71
|
+
stats(): import("../../store/store.ts").ProviderStats[];
|
|
72
|
+
storageStats(): import("../../store/store.ts").StorageStats;
|
|
73
|
+
/** Paginated browse for the management list — unlike {@link search}, includes `remoteMissing` rows. */
|
|
74
|
+
browse(query: string, provider: ChatProvider | undefined, limit: number, offset: number): {
|
|
75
|
+
items: ManagedConversation[];
|
|
76
|
+
total: number;
|
|
77
|
+
};
|
|
78
|
+
/** Permanently delete a synced conversation; refused while a sync could resurrect it mid-delete. */
|
|
79
|
+
remove(uriId: string): Promise<boolean>;
|
|
80
|
+
removeProvider(provider: ChatProvider): Promise<number>;
|
|
81
|
+
removeOlderThan(days: number): Promise<number>;
|
|
82
|
+
removeRemoteMissing(): Promise<number>;
|
|
83
|
+
removeOldAccounts(): Promise<number>;
|
|
84
|
+
/** Durable per-provider sync status, independent of any single in-flight job. */
|
|
85
|
+
syncStates(): import("../../store/store.ts").ProviderSyncState[];
|
|
86
|
+
getSettings(): SettingsRecord;
|
|
87
|
+
restartDaemon(signal?: AbortSignal): Promise<boolean>;
|
|
88
|
+
discoverOpenCli(signal?: AbortSignal): Promise<OpenCliDiscovery>;
|
|
89
|
+
installOpenCli(signal?: AbortSignal): Promise<OpenCliDiscovery>;
|
|
90
|
+
updateSettings(value: unknown): Promise<SettingsRecord>;
|
|
91
|
+
health(signal?: AbortSignal): Promise<import("../../opencli.ts").OpenCliHealth>;
|
|
92
|
+
profiles(signal?: AbortSignal): Promise<import("../../opencli.ts").BrowserProfile[]>;
|
|
93
|
+
private runner;
|
|
94
|
+
installAdapter(signal?: AbortSignal): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* (Re)arm the background sync timer from the current settings.
|
|
97
|
+
*
|
|
98
|
+
* Clears its own timers rather than swapping a `ctx.effect` disposer:
|
|
99
|
+
* disposing is asynchronous, so re-arming through it left a window — one
|
|
100
|
+
* that a couple of quick settings saves is enough to hit — where two
|
|
101
|
+
* intervals were live at once.
|
|
102
|
+
*/
|
|
103
|
+
private armAutoSync;
|
|
104
|
+
/**
|
|
105
|
+
* How long to wait before a catch-up pass, or undefined when the last one
|
|
106
|
+
* is recent enough that the regular interval will do.
|
|
107
|
+
*
|
|
108
|
+
* A plain interval only ever fires a full period after arming, so enabling
|
|
109
|
+
* background sync — or restarting dsh after a day off — used to mean the
|
|
110
|
+
* mirror stayed stale for up to a day before anything happened.
|
|
111
|
+
*/
|
|
112
|
+
private catchUpDelay;
|
|
113
|
+
private clearAutoSync;
|
|
114
|
+
}
|