@tiagojct/subsub 0.9.0 → 0.9.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 CHANGED
@@ -18,7 +18,7 @@ A Zotero librarian and research assistant that runs on your computer, in your br
18
18
  * **Code-Enforced Safety Gate**: The model **cannot** modify your library without your explicit approval. Every change is calculated first as a dry-run preview (+added in green, -removed in red) and executed only when you confirm.
19
19
  * **Full Journal & Reversible Edits**: Every library modification is recorded with a unique journal ID and can be reverted anytime using `/undo`.
20
20
  * **Two Dedicated Roles**:
21
- * 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *Runs completely offline with no web search access.*
21
+ * 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *Has no web search tools; imports and PDFs come from Crossref, Unpaywall and Open Library.*
22
22
  * 🔬 **The Researcher**: Reads items, searches PubMed and OpenAlex, finds gaps, checks citations in manuscripts, and writes structured Markdown notes in your Sub-Sub folder (it can sit inside an Obsidian vault). *Cannot modify library records.*
23
23
  * **Local & Private**: Listens strictly on `127.0.0.1` protected by an authenticated single-origin session cookie. Your library contents remain on your computer.
24
24
  * **Modern Web Interface & Terminal UI**: Use Sub-Sub in your browser with desktop shortcuts (`~/Applications/Sub-Sub.app`, Start menu, or `.desktop`), or launch it right in your terminal with adaptive color themes.
package/dist/args.js CHANGED
@@ -65,3 +65,26 @@ export function coerceArgs(original, input) {
65
65
  }
66
66
  return input;
67
67
  }
68
+ /**
69
+ * Arguments that name a file in the user's working folder (a manuscript, a report
70
+ * folder, a bibliography to write). The servers run in their own working folder, so
71
+ * Sub-Sub makes these paths absolute against its own, as pi's file tools read them.
72
+ * Note paths (tag reviews, the import queue, linked notes) are not here: the servers
73
+ * read those from the Sub-Sub folder.
74
+ */
75
+ export const CWD_PATH_ARGS = {
76
+ scholar_check_manuscript: ["path"],
77
+ scholar_export_bibliography: ["output_path"],
78
+ verify_check_manuscript: ["path", "report_dir"],
79
+ verify_prepare_claims: ["path", "report_dir"],
80
+ verify_record_claims: ["path", "report_dir"],
81
+ };
82
+ /** Make the working-folder paths of a tool call absolute. Changes input in place. */
83
+ export function resolvePathArgs(tool, input, toAbsolute) {
84
+ for (const k of CWD_PATH_ARGS[tool] ?? []) {
85
+ const v = input[k];
86
+ if (typeof v === "string" && v.trim())
87
+ input[k] = toAbsolute(v);
88
+ }
89
+ return input;
90
+ }
package/dist/config.js CHANGED
@@ -24,7 +24,7 @@ import { dirname, join, resolve } from "node:path";
24
24
  import { isObsidianVault, settingsFile } from "./layout.js";
25
25
  export const PROFILES = ["reader", "scholar", "author", "editor"];
26
26
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
27
- export const SERVER_VERSION = "0.5.0";
27
+ export const SERVER_VERSION = "0.5.1";
28
28
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
29
29
  export const STARBUCK_VERSION = "0.2.0";
30
30
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
package/dist/layout.js CHANGED
@@ -24,6 +24,15 @@ export const SETTINGS_FILES = { tags: "Zotero tags.md", rules: "Zotero agent.md"
24
24
  export function isObsidianVault(dir) {
25
25
  return existsSync(join(dir, ".obsidian"));
26
26
  }
27
+ /** The Obsidian vault that holds a folder: the folder itself or the nearest parent with .obsidian/. */
28
+ export function obsidianRoot(dir) {
29
+ for (let d = dir;; d = dirname(d)) {
30
+ if (isObsidianVault(d))
31
+ return d;
32
+ if (dirname(d) === d)
33
+ return undefined;
34
+ }
35
+ }
27
36
  /** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
28
37
  export function subsubFolder(answer) {
29
38
  return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
package/dist/subsub.js CHANGED
@@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
  import { Bridge } from "./bridge.js";
21
21
  import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
22
22
  import { profileList, profileSpec, promptBlocked } from "./profiles.js";
23
- import { coerceArgs, loosenArrays } from "./args.js";
23
+ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
24
24
  import { applyEdits, checkLiteratureNote } from "./notes.js";
25
25
  import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
26
26
  import { describeArgs, formatPreview } from "./preview.js";
@@ -291,6 +291,7 @@ export async function createSubsub(pi, deps = {}) {
291
291
  const schema = schemas.get(name);
292
292
  if (schema)
293
293
  coerceArgs(schema, input);
294
+ resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
294
295
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
295
296
  const why = unavailableReason(name, mode, registered(), profile);
296
297
  if (why) {
package/dist/web.js CHANGED
@@ -23,6 +23,7 @@ import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
24
  import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
25
25
  import { TESTED_MODELS } from "./init.js";
26
+ import { obsidianRoot } from "./layout.js";
26
27
  import { realish, within } from "./paths.js";
27
28
  export function parseWebArgs(args) {
28
29
  const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
@@ -599,7 +600,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
599
600
  // Documents only: a file that the model wrote must never be run.
600
601
  if (!OPENABLE.has(extname(target).toLowerCase()))
601
602
  return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
602
- if (vault && within(target, vault) && existsSync(join(vault, ".obsidian")))
603
+ if (vault && within(target, vault) && obsidianRoot(vault))
603
604
  openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
604
605
  else
605
606
  openExternal(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/args.ts CHANGED
@@ -62,3 +62,27 @@ export function coerceArgs(original: Schema, input: Record<string, unknown>): Re
62
62
  }
63
63
  return input;
64
64
  }
65
+
66
+ /**
67
+ * Arguments that name a file in the user's working folder (a manuscript, a report
68
+ * folder, a bibliography to write). The servers run in their own working folder, so
69
+ * Sub-Sub makes these paths absolute against its own, as pi's file tools read them.
70
+ * Note paths (tag reviews, the import queue, linked notes) are not here: the servers
71
+ * read those from the Sub-Sub folder.
72
+ */
73
+ export const CWD_PATH_ARGS: Record<string, string[]> = {
74
+ scholar_check_manuscript: ["path"],
75
+ scholar_export_bibliography: ["output_path"],
76
+ verify_check_manuscript: ["path", "report_dir"],
77
+ verify_prepare_claims: ["path", "report_dir"],
78
+ verify_record_claims: ["path", "report_dir"],
79
+ };
80
+
81
+ /** Make the working-folder paths of a tool call absolute. Changes input in place. */
82
+ export function resolvePathArgs(tool: string, input: Record<string, unknown>, toAbsolute: (p: string) => string): Record<string, unknown> {
83
+ for (const k of CWD_PATH_ARGS[tool] ?? []) {
84
+ const v = input[k];
85
+ if (typeof v === "string" && v.trim()) input[k] = toAbsolute(v);
86
+ }
87
+ return input;
88
+ }
package/src/config.ts CHANGED
@@ -29,7 +29,7 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
29
29
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
30
30
 
31
31
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
32
- export const SERVER_VERSION = "0.5.0";
32
+ export const SERVER_VERSION = "0.5.1";
33
33
 
34
34
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
35
35
  export const STARBUCK_VERSION = "0.2.0";
package/src/layout.ts CHANGED
@@ -30,6 +30,14 @@ export function isObsidianVault(dir: string): boolean {
30
30
  return existsSync(join(dir, ".obsidian"));
31
31
  }
32
32
 
33
+ /** The Obsidian vault that holds a folder: the folder itself or the nearest parent with .obsidian/. */
34
+ export function obsidianRoot(dir: string): string | undefined {
35
+ for (let d = dir; ; d = dirname(d)) {
36
+ if (isObsidianVault(d)) return d;
37
+ if (dirname(d) === d) return undefined;
38
+ }
39
+ }
40
+
33
41
  /** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
34
42
  export function subsubFolder(answer: string): string {
35
43
  return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
package/src/subsub.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  who,
37
37
  } from "./config.ts";
38
38
  import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
39
- import { coerceArgs, loosenArrays } from "./args.ts";
39
+ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
40
40
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
41
41
  import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
42
42
  import { describeArgs, formatPreview } from "./preview.ts";
@@ -321,6 +321,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
321
321
  // Previews and the real call must see the same, repaired arguments.
322
322
  const schema = schemas.get(name);
323
323
  if (schema) coerceArgs(schema, input);
324
+ resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
324
325
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
325
326
  const why = unavailableReason(name, mode, registered(), profile);
326
327
  if (why) {
package/src/web.ts CHANGED
@@ -24,6 +24,7 @@ import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
25
  import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
+ import { obsidianRoot } from "./layout.ts";
27
28
  import { realish, within } from "./paths.ts";
28
29
 
29
30
  // ---------------------------------------------------------------- options
@@ -610,7 +611,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
610
611
  if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
611
612
  // Documents only: a file that the model wrote must never be run.
612
613
  if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
613
- if (vault && within(target, vault) && existsSync(join(vault, ".obsidian"))) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
614
+ if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
614
615
  else openExternal(target);
615
616
  return send(res, 200, { ok: true });
616
617
  }