Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions hindsight-integrations/obsidian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Open **Settings → Hindsight**:
| Bank name | `obsidian` | Shared bank for all your vaults (separated by `vault:` tags) |
| Include / exclude folders | — | Limit which notes sync |
| Sync on edit | on | Re-ingest notes automatically as you edit |
| Observation consolidation | server default | Optional named observation scope for newly ingested content |
| Default chat depth | low | Reflect budget for chat answers |
| Remember conversations | **off** | When on, chat turns are stored in Hindsight (creates memory outside your vault) |
| Prefix document IDs | on | Vault-prefixes ids so shared-bank vaults don't collide; turn off only for a single-vault setup |
Expand All @@ -83,11 +84,13 @@ hindsight-obsidian-sync reconcile \
hindsight-obsidian-sync reconcile --vault ~/Vaults/Brain --bank my-vault --watch
```

`--api-url` / `--api-token` fall back to `HINDSIGHT_API_URL` / `HINDSIGHT_API_TOKEN`. Other flags: `--include <folder>` / `--exclude <folder>` (repeatable), `--vault-name <name>` (defaults to the vault dir name), `--prefix-doc-id` (prefix document ids with the vault name for multi-vault banks), `--index <file>`, and `--help`.
`--api-url` / `--api-token` fall back to `HINDSIGHT_API_URL` / `HINDSIGHT_API_TOKEN`. Other flags: `--include <folder>` / `--exclude <folder>` (repeatable), `--vault-name <name>` (defaults to the vault dir name), `--prefix-doc-id` (prefix document ids with the vault name for multi-vault banks), `--observation-scopes <combined|shared|per_tag|all_combinations>`, `--index <file>`, and `--help`.

Observation consolidation is optional. When unset, the integration omits `observation_scopes` and preserves the Hindsight server's default. Source tags are still retained for filtering and provenance in every mode. Changing this setting affects new ingestion work; it does not automatically move existing memories or observations into the new scope, so historical scope migration requires deliberate document reingestion.

The sync index (the CLI's equivalent of the plugin's `data.json`) defaults to a **per-target** file under `~/.hindsight/obsidian/` — `<vault>-<bank>-<fingerprint>.json`, deliberately **outside** the vault so Obsidian Sync never propagates it. The index is bound to the destination it was built against (API origin, bank, vault path, and the `--vault-name`/`--prefix-doc-id` document-id namespace); pointing a saved index at a different bank or API — whether via the default path or an explicit `--index` — **fails closed** with an actionable message rather than silently skipping files or mis-attributing deletes to a bank it never wrote to. Changing `--include`/`--exclude` on the _same_ destination is fine (it reuses the index and prunes newly-excluded notes it owns there).

> **Running the CLI and the plugin against the same bank + vault?** Keep their scope config identical (`--include`/`--exclude`, `--vault-name`, `--prefix-doc-id` matching the plugin's settings). They each keep their own index, and a reconcile prunes only what its own index tracks — so mismatched scope on the two frontends could let one prune documents the other owns.
> **Running the CLI and the plugin against the same bank + vault?** Keep their config identical (`--include`/`--exclude`, `--vault-name`, `--prefix-doc-id`, and `--observation-scopes` matching the plugin's settings). They each keep their own index, and a reconcile prunes only what its own index tracks — so mismatched folder/id scope on the two frontends could let one prune documents the other owns, while mismatched observation scope would produce inconsistent consolidation.

## How it works

Expand Down
1 change: 1 addition & 0 deletions hindsight-integrations/obsidian/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class HindsightClient {
update_mode: options.updateMode ?? "replace",
};
if (options.tags?.length) item.tags = options.tags;
if (options.observationScopes) item.observation_scopes = options.observationScopes;
if (options.metadata && Object.keys(options.metadata).length) item.metadata = options.metadata;
if (options.timestamp) item.timestamp = options.timestamp;

Expand Down
1 change: 1 addition & 0 deletions hindsight-integrations/obsidian/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ export default class HindsightPlugin extends Plugin {
excludeFolders: this.settings.excludeFolders,
vaultName: this.app.vault.getName(),
prefixDocId: this.settings.prefixDocId,
observationScopes: this.settings.observationScopes,
};
}

Expand Down
19 changes: 19 additions & 0 deletions hindsight-integrations/obsidian/src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { parseArgs } from "node:util";
import { HindsightClient } from "../client";
import { SyncEngine, type ReconcileSummary, type SyncConfig } from "../sync";
import type { Transport } from "../transport";
import type { ObservationScopes } from "../types";
import { fetchTransport } from "./fetch-transport";
import { FsVault } from "./fs-vault";
import {
Expand All @@ -38,6 +39,7 @@ export interface CliOptions {
exclude: string[];
vaultName: string;
prefixDocId: boolean;
observationScopes?: ObservationScopes;
indexPath: string;
watch: boolean;
/** Destination the sync index is bound to (issue #3257). */
Expand All @@ -58,6 +60,9 @@ Options:
--exclude <folder> Skip this folder (repeatable)
--vault-name <name> Vault name for tags/ids (default: the vault dir name)
--prefix-doc-id Prefix document ids with the vault name (multi-vault banks)
--observation-scopes <mode>
Observation consolidation: combined, shared, per_tag,
or all_combinations (default: server behavior)
--index <file> Sync-index JSON path (default: a per-target file under
~/.hindsight/obsidian/, scoped to bank + API + vault)
--watch Keep running and sync changes as they happen
Expand All @@ -70,6 +75,16 @@ export class UsageError extends Error {}
/** Thrown for `--help`; its message is printed to stdout (exit 0). */
export class HelpRequested extends Error {}

const OBSERVATION_SCOPES = ["combined", "shared", "per_tag", "all_combinations"] as const;

function parseObservationScopes(value: string | undefined): ObservationScopes | undefined {
if (value === undefined) return undefined;
if (OBSERVATION_SCOPES.includes(value as ObservationScopes)) return value as ObservationScopes;
throw new UsageError(
`--observation-scopes must be one of: ${OBSERVATION_SCOPES.join(", ")}`
);
Comment thread
omgbabyweb marked this conversation as resolved.
Outdated
}

/** Parse argv into fully-resolved options (applying env fallbacks + defaults). */
export function parseCliArgs(argv: string[]): CliOptions {
const { values, positionals } = parseArgs({
Expand All @@ -84,6 +99,7 @@ export function parseCliArgs(argv: string[]): CliOptions {
exclude: { type: "string", multiple: true },
"vault-name": { type: "string" },
"prefix-doc-id": { type: "boolean", default: false },
"observation-scopes": { type: "string" },
index: { type: "string" },
watch: { type: "boolean", default: false },
help: { type: "boolean", default: false },
Expand All @@ -104,6 +120,7 @@ export function parseCliArgs(argv: string[]): CliOptions {
const vaultName = values["vault-name"] || basename(vault);
const apiUrl = values["api-url"] || process.env.HINDSIGHT_API_URL || "";
if (!apiUrl) throw new UsageError("--api-url is required (or set HINDSIGHT_API_URL)");
const observationScopes = parseObservationScopes(values["observation-scopes"]);

const identity: IndexIdentity = {
apiOrigin: canonicalApiOrigin(apiUrl),
Expand All @@ -122,6 +139,7 @@ export function parseCliArgs(argv: string[]): CliOptions {
exclude: values.exclude ?? [],
vaultName,
prefixDocId: values["prefix-doc-id"] ?? false,
...(observationScopes ? { observationScopes } : {}),
indexPath: values.index || defaultIndexPath(identity),
watch: values.watch ?? false,
identity,
Expand All @@ -135,6 +153,7 @@ export function buildConfig(opts: CliOptions): SyncConfig {
excludeFolders: opts.exclude,
vaultName: opts.vaultName,
prefixDocId: opts.prefixDocId,
...(opts.observationScopes ? { observationScopes: opts.observationScopes } : {}),
};
}

Expand Down
27 changes: 26 additions & 1 deletion hindsight-integrations/obsidian/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type App, Notice, PluginSettingTab, Setting } from "obsidian";
import type HindsightPlugin from "./main";
import type { Budget } from "./types";
import type { Budget, ObservationScopes } from "./types";

export interface HindsightSettings {
apiUrl: string;
Expand All @@ -14,6 +14,8 @@ export interface HindsightSettings {
/** DESIGN.md §0.5: OFF by default — keeps Hindsight from becoming a 2nd source of truth. */
rememberConversations: boolean;
prefixDocId: boolean;
/** Omitted to preserve the Hindsight server's default observation scope. */
observationScopes?: ObservationScopes;
/** Log reflect requests/responses to the console (open devtools to view). */
debugLogging: boolean;
/** Chat disclosure open/closed state — remembered across sessions (last value wins). */
Expand Down Expand Up @@ -128,6 +130,29 @@ export class HindsightSettingTab extends PluginSettingTab {
})
);

new Setting(containerEl)
.setName("Observation consolidation")
.setDesc(
"How observations are grouped for newly ingested content. Source tags remain available for filtering and provenance. Changing this does not rescope existing memories."
Comment thread
omgbabyweb marked this conversation as resolved.
)
.addDropdown((d) =>
d
.addOptions({
"": "Server default",
combined: "Combined tags",
shared: "Shared",
per_tag: "Per tag",
all_combinations: "All tag combinations",
})
.setValue(this.plugin.settings.observationScopes ?? "")
.onChange(async (v) => {
this.plugin.settings.observationScopes = v
? (v as ObservationScopes)
: undefined;
await this.plugin.saveSettings();
})
);

new Setting(containerEl)
.setName("Default chat depth")
.setDesc("Reflect budget for chat answers.")
Expand Down
4 changes: 4 additions & 0 deletions hindsight-integrations/obsidian/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { createHash } from "node:crypto";
import type { HindsightClient } from "./client";
import { normalizeNote } from "./frontmatter";
import type { ObservationScopes } from "./types";

export interface SyncFile {
path: string;
Expand Down Expand Up @@ -38,6 +39,8 @@ export interface SyncConfig {
vaultName: string;
/** Prefix document ids with the vault name (for multi-vault shared banks). */
prefixDocId: boolean;
/** Omit to preserve the Hindsight server's default consolidation scope. */
observationScopes?: ObservationScopes;
}

export interface ReconcileSummary {
Expand Down Expand Up @@ -167,6 +170,7 @@ export class SyncEngine {

await this.client.retain(this.config.bankId, this.docId(file.path), note.body, {
tags,
observationScopes: this.config.observationScopes,
metadata,
timestamp: note.timestamp ?? isoFromMillis(file.stat.ctime),
updateMode: "replace",
Expand Down
3 changes: 3 additions & 0 deletions hindsight-integrations/obsidian/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

export type Budget = "low" | "mid" | "high";

export type ObservationScopes = "combined" | "shared" | "per_tag" | "all_combinations";

export type TagMatch = "any" | "all" | "any_strict" | "all_strict";

/** A leaf tag filter. `all_strict` = AND match that also excludes untagged memories. */
Expand All @@ -24,6 +26,7 @@ export type TagGroup = TagLeaf | TagAnd;

export interface RetainOptions {
tags?: string[];
observationScopes?: ObservationScopes;
metadata?: Record<string, string>;
/** ISO 8601 timestamp, or "unset" for timeless content. */
timestamp?: string;
Expand Down
16 changes: 15 additions & 1 deletion hindsight-integrations/obsidian/tests/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,24 @@ describe("HindsightClient", () => {
expect(body.tag_groups).toBeUndefined();
});

it("retain omits the tags field when no tags are given", async () => {
it("retain sends a named observation scope beside existing tags", async () => {
const client = new HindsightClient("https://api.example.com", undefined, mock);
await client.retain("b", "Note.md", "body", {
tags: ["vault:Notes", "topic:planning"],
observationScopes: "shared",
});
const body = JSON.parse(lastCall().body ?? "{}");
expect(body.items[0]).toMatchObject({
tags: ["vault:Notes", "topic:planning"],
observation_scopes: "shared",
});
});

it("retain omits optional tags and observation scopes when unset", async () => {
const client = new HindsightClient("https://api.example.com", undefined, mock);
await client.retain("b", "Note.md", "body");
const body = JSON.parse(lastCall().body ?? "{}");
expect(body.items[0].tags).toBeUndefined();
expect(body.items[0].observation_scopes).toBeUndefined();
});
});
61 changes: 61 additions & 0 deletions hindsight-integrations/obsidian/tests/node/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ describe("parseCliArgs", () => {
expect(o.apiToken).toBe("envtok");
});

it("accepts named observation scopes and rejects unsupported values", () => {
expect(parseCliArgs([...base, "--observation-scopes", "shared"]).observationScopes).toBe(
"shared"
);
expect(parseCliArgs([...base, "--observation-scopes", "all_combinations"]).observationScopes).toBe(
"all_combinations"
);
expect(() => parseCliArgs([...base, "--observation-scopes", "custom"])).toThrow(
/observation-scopes/
);
});

it("requires --vault, --bank and an api url", () => {
expect(() => parseCliArgs(["--bank", "b", "--api-url", "h"])).toThrow(UsageError);
expect(() => parseCliArgs(["--vault", "/v", "--api-url", "h"])).toThrow(UsageError);
Expand All @@ -108,6 +120,22 @@ describe("buildConfig", () => {
prefixDocId: true,
});
});

it("maps an explicitly configured observation scope", () => {
const cfg = buildConfig(
parseCliArgs([
"--vault",
"/v",
"--bank",
"b",
"--api-url",
"h",
"--observation-scopes",
"shared",
])
);
expect(cfg.observationScopes).toBe("shared");
});
});

describe("createWatchHandlers", () => {
Expand Down Expand Up @@ -163,6 +191,39 @@ describe("runCli", () => {
expect(init?.method).toBe("POST");
});

it("sends observation_scopes through the full CLI retain path", async () => {
await writeFile(join(root, "note.md"), "# Note\nremember this");
let retained: Record<string, unknown> | undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}"));
retained = body.items?.[0];
return { status: 200, text: async () => "{}" } as unknown as Response;
})
);

expect(
await runCli(
[
"--vault",
root,
"--bank",
"b",
"--api-url",
"https://h",
"--observation-scopes",
"shared",
"--index",
join(root, "scope.json"),
],
() => {}
)
).toBe(0);
expect(retained).toMatchObject({ observation_scopes: "shared", update_mode: "replace" });
expect(retained?.tags).toEqual(expect.arrayContaining([expect.stringMatching(/^vault:/)]));
});

it("prints help and exits 0", async () => {
const out: string[] = [];
expect(await runCli(["--help"], (m) => out.push(m))).toBe(0);
Expand Down
47 changes: 47 additions & 0 deletions hindsight-integrations/obsidian/tests/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,53 @@ describe("SyncEngine", () => {
expect(opts.metadata.vault).toBe("Personal");
});

it("passes observation scope through without changing note provenance", async () => {
const created = Date.UTC(2026, 2, 15);
const updated = Date.UTC(2026, 5, 20);
const files = {
"Work/note.md": {
content: "---\ntags: [planning]\nsource: meeting\n---\n# Decision\nkeep it simple",
mtime: updated,
ctime: created,
},
};
const { engine, client } = makeEngine(files, {}, { observationScopes: "shared" });

await engine.reconcile();

const [, docId, content, opts] = client.retain.mock.calls[0] as [
string,
string,
string,
{
tags: string[];
metadata: Record<string, string>;
timestamp: string;
updateMode: string;
observationScopes?: string;
},
];
expect(docId).toBe("Work/note.md");
expect(content).toBe("# Decision\nkeep it simple");
expect(opts).toMatchObject({
observationScopes: "shared",
updateMode: "replace",
timestamp: new Date(created).toISOString(),
metadata: { source: "meeting", vault: "Vault", path: "Work/note.md" },
});
expect(opts.tags).toEqual(
expect.arrayContaining(["planning", "vault:Vault", "folder:Work", "created:2026-03"])
);
});

it("omits observation scope from retain options when unset", async () => {
const files = { "a.md": { content: "body", mtime: 1, ctime: 0 } };
const { engine, client } = makeEngine(files);
await engine.reconcile();
const opts = client.retain.mock.calls[0][3] as Record<string, unknown>;
expect(opts.observationScopes).toBeUndefined();
});

it("re-ingests (updated) when content changes", async () => {
const index: SyncIndex = {};
const filesV1 = { "a.md": { content: "v1", mtime: 1, ctime: 0 } };
Expand Down