mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers) (#2119)
* feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers)
A Zapier Platform CLI app that brings Hindsight memory into Zaps.
Actions:
- Retain Memory (create) -> POST /v1/default/banks/{bank}/memories
- Recall Memories (search) -> POST .../memories/recall
- Reflect (search) -> POST .../reflect
Triggers (instant, via Hindsight's webhook API — subscribe POSTs /webhooks,
unsubscribe DELETEs it):
- Retain Completed, Consolidation Completed, Memory Defense Triggered
Auth: API key as Bearer token, Cloud default with self-hosted override; the
Bank field is a dynamic dropdown from GET /v1/default/banks.
Built on zapier-platform-core 19; 'private': true so the npm release path can
never publish it (Zapier publishing is manual via zapier push/promote, not
release-integration.yml — and zapier is intentionally NOT in VALID_INTEGRATIONS).
Adds test-zapier-integration CI job (npm install -> zapier validate -> npm test)
and a repo README row. 15 mocha/nock unit tests; 'zapier validate' is
structurally clean.
Docs-site gallery card + doc page + icon are a follow-up (need the official
Zapier brand asset; omitted here to keep build-docs green).
* fix(zapier): make apiKey optional for no-auth self-hosted + prettier-clean
- authentication.js: apiKey now optional (required: false). The middleware only
adds the Bearer header when a key is present, so you can connect to a
self-hosted instance running without auth by leaving it blank; Cloud still
requires a working key (blank -> 401 fails the connection test).
- README: document self-hosted / localhost usage, the optional key, and correct
the CLI binary name to 'zapier-platform' (v19 renamed it from 'zapier'); show
the .env approach so 'zapier invoke' needs no global install.
- Run prettier across the integration (fixes pre-existing format drift that was
failing verify-generated-files on this branch).
zapier validate still structurally sound; 15 tests pass.
* fix(zapier): correct reflect answer field + recall output shape (found via live test)
Extensive live testing against Hindsight Cloud surfaced two response-shape bugs
the mocked unit tests missed (they mocked the wrong shapes):
- reflect: the synthesized answer is in the response's `text` field, not
`answer`. searches/reflect read `data.answer` (undefined), so a Zap got no
answer. Now reads `data.text` and surfaces it as `answer`. Test mock fixed to
use the real `text` field so it actually guards this.
- recall: results carry no numeric `score`, and the fact-type field is `type`
(not `fact_type`). Corrected the sample + outputFields so the Zap editor only
advertises fields that actually populate; test mock made realistic.
Verified live end-to-end: auth, bank dropdown, retain (full + minimal), recall
(real fact extraction), reflect (now returns the grounded answer), and the
webhook subscribe/list/delete lifecycle. 15 unit tests pass; zapier validate clean.
* docs(zapier): correct .env auth-field prefix to authData_ in README
zapier invoke reads .env auth fields with the authData_ prefix (e.g.
authData_apiKey, authData_apiUrl), not bare apiKey/apiUrl. Confirmed against a
working local .env during live testing.
* docs(zapier): add integrations gallery card + doc page
- gallery entry in integrations.json (id zapier, official, category framework)
- doc page docs-integrations/zapier.md (actions + REST Hook triggers, setup)
- official Zapier logo at static/img/icons/zapier.png
check-integrations passes (forward: entry → doc page); JSON valid; prettier-clean.
* feat(zapier): verify webhook HMAC signatures + optional async retain (review notes 2 & 4)
#2 — Webhook signature verification (was: relying only on Zapier's unguessable URL):
- performSubscribe now generates a random 32-byte secret and registers it with
the webhook; the secret is stored in subscribeData.
- perform verifies the X-Hindsight-Signature: sha256=<hmac> header (HMAC-SHA256
of the raw body) and rejects mismatches. (Corrected the header name — the API
sends X-Hindsight-Signature, not X-Webhook-Signature; body is delivered
byte-for-byte via content=, so the recomputed HMAC matches.)
#4 — Optional 'Process asynchronously' toggle on Retain (default false). Lets
users with very large content avoid Zapier's action timeout; pairs with the
Retain Completed trigger.
17 unit tests pass (added valid/invalid signature cases); zapier validate clean.
This commit is contained in:
@@ -49,6 +49,7 @@ jobs:
|
||||
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
|
||||
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
|
||||
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
|
||||
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
|
||||
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
|
||||
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
|
||||
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
|
||||
@@ -161,6 +162,8 @@ jobs:
|
||||
- 'hindsight-integrations/cursor/**'
|
||||
integrations-n8n:
|
||||
- 'hindsight-integrations/n8n/**'
|
||||
integrations-zapier:
|
||||
- 'hindsight-integrations/zapier/**'
|
||||
integrations-cloudflare-oauth-proxy:
|
||||
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
|
||||
integrations-superagent:
|
||||
@@ -730,6 +733,37 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/n8n
|
||||
run: npm run build
|
||||
|
||||
test-zapier-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm install --no-fund --no-audit
|
||||
|
||||
- name: Validate app definition
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm run validate
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/zapier
|
||||
run: npm test
|
||||
|
||||
test-hindsight-agent-sdk:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
sidebar_position: 36
|
||||
title: "Zapier Persistent Memory with Hindsight | Integration"
|
||||
description: "Add long-term memory to your Zaps with Hindsight. Retain, Recall, and Reflect actions plus instant memory-event triggers connect Hindsight to 7,000+ apps."
|
||||
---
|
||||
|
||||
# Zapier
|
||||
|
||||
Long-term agent memory for [Zapier](https://zapier.com) via [Hindsight](https://hindsight.vectorize.io). The Hindsight Zapier app adds three actions — **Retain**, **Recall**, **Reflect** — plus instant **triggers** that start a Zap when a memory event fires, so memory flows between Hindsight and 7,000+ apps.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Zapier connects everything: Gmail, Slack, Sheets, HubSpot, Notion, forms, and thousands more. On its own, those Zaps are **stateless**. With Hindsight you can:
|
||||
|
||||
- **Retain** every closed ticket, form submission, or call summary into a memory bank
|
||||
- **Recall** relevant context before an AI step so the model sees prior history
|
||||
- **Reflect** to get a synthesized, memory-grounded answer right inside a Zap
|
||||
- **Trigger** a Zap the moment a memory operation completes (e.g. notify Slack when consolidation finishes)
|
||||
|
||||
## Setup
|
||||
|
||||
:::tip Recommended: Hindsight Cloud
|
||||
[Sign up free](https://ui.hindsight.vectorize.io/signup) and grab an API key — no self-hosting required.
|
||||
:::
|
||||
|
||||
1. **Sign up** at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) (free tier) or [self-host](/developer/installation)
|
||||
2. **Get an API key** (`hsk_...`) from the Hindsight dashboard
|
||||
3. **In Zapier**, add a Hindsight step and connect your account with the API key (the API URL defaults to Hindsight Cloud; point it at your own instance for self-hosted — leave the key blank if it runs without auth)
|
||||
|
||||
## Actions
|
||||
|
||||
### Retain
|
||||
|
||||
Store content in a bank. Hindsight extracts facts asynchronously after the call returns.
|
||||
|
||||
| Field | Description |
|
||||
| --------- | --------------------------------------------------------------------- |
|
||||
| Bank | Memory bank to store in (dynamic dropdown; auto-created on first use) |
|
||||
| Content | Free text to retain |
|
||||
| Context | Optional context for the content |
|
||||
| Tags | Comma-separated tags |
|
||||
| Timestamp | When the content occurred (defaults to now) |
|
||||
|
||||
### Recall (search)
|
||||
|
||||
Search a bank for memories relevant to a query.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ---------------------- |
|
||||
| Bank | Memory bank to search |
|
||||
| Query | Natural-language query |
|
||||
| Budget | `low` / `mid` / `high` |
|
||||
| Tags / Tags Match | Optional tag filter |
|
||||
|
||||
### Reflect (search)
|
||||
|
||||
Get an LLM-synthesized answer grounded in the bank's memories.
|
||||
|
||||
| Field | Description |
|
||||
| ------ | ---------------------- |
|
||||
| Bank | Memory bank |
|
||||
| Query | Question to answer |
|
||||
| Budget | `low` / `mid` / `high` |
|
||||
|
||||
## Triggers
|
||||
|
||||
Instant triggers (REST Hooks) that fire when a memory event completes in a bank:
|
||||
|
||||
| Trigger | Fires when |
|
||||
| ---------------------------- | ------------------------------------------------------------- |
|
||||
| **Retain Completed** | An asynchronous retain finishes processing |
|
||||
| **Consolidation Completed** | Memory consolidation synthesizes observations / mental models |
|
||||
| **Memory Defense Triggered** | The memory-defense filter redacts or blocks incoming content |
|
||||
|
||||
## Example Zaps
|
||||
|
||||
**Support assistant** — a closed-ticket trigger (Zendesk) → Hindsight **Retain** the resolution. New ticket → Hindsight **Recall** similar past issues → OpenAI drafts the first reply.
|
||||
|
||||
**Memory digest** — Hindsight **Consolidation Completed** trigger → format the new observations → post to Slack so the team sees what the agent learned.
|
||||
|
||||
**Daily prep** — a calendar trigger → Hindsight **Reflect** ("What do we know about this prospect?") → append the answer to the prep doc.
|
||||
|
||||
## Source
|
||||
|
||||
- GitHub: [`hindsight-integrations/zapier`](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/zapier)
|
||||
@@ -320,6 +320,16 @@
|
||||
"link": "/sdks/integrations/n8n",
|
||||
"icon": "/img/icons/n8n.png"
|
||||
},
|
||||
{
|
||||
"id": "zapier",
|
||||
"name": "Zapier",
|
||||
"description": "Long-term memory for your Zaps. Retain, Recall, and Reflect actions plus instant memory-event triggers connect Hindsight to 7,000+ apps.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "framework",
|
||||
"link": "/sdks/integrations/zapier",
|
||||
"icon": "/img/icons/zapier.png"
|
||||
},
|
||||
{
|
||||
"id": "openai-agents",
|
||||
"name": "OpenAI Agents SDK",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -45,6 +45,7 @@ Each integration lives in its own subdirectory with its own README, configuratio
|
||||
| Integration | What it does |
|
||||
| --- | --- |
|
||||
| [**n8n**](./n8n) | Community node — drop retain/recall/reflect into any n8n workflow. |
|
||||
| [**Zapier**](./zapier) | Zapier app — retain/recall/reflect actions plus instant memory-event triggers. |
|
||||
| [**Dify**](./dify) | Persistent memory for Dify apps. |
|
||||
| [**Flowise**](./flowise) | Memory nodes for Flowise flows. |
|
||||
| [**Vapi**](./vapi) | Persistent memory for Vapi voice agents. |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
build/
|
||||
.env
|
||||
.environment
|
||||
.zapierapprc
|
||||
*.log
|
||||
@@ -0,0 +1,77 @@
|
||||
# Hindsight for Zapier
|
||||
|
||||
A [Zapier](https://zapier.com) app that brings [Hindsight](https://hindsight.vectorize.io) long-term memory into your Zaps — store content, search memories, get grounded answers, and start Zaps from memory events.
|
||||
|
||||
Built with the [Zapier Platform CLI](https://platform.zapier.com/). The source lives in the Hindsight monorepo for versioning and CI; the app itself is published to Zapier's platform with `zapier-platform push` / `zapier-platform promote` (see [Publishing](#publishing)).
|
||||
|
||||
## What's included
|
||||
|
||||
### Actions
|
||||
|
||||
- **Retain Memory** (create) — store content in a memory bank (`POST /memories`).
|
||||
- **Recall Memories** (search) — search a bank with a natural-language query (`POST /memories/recall`).
|
||||
- **Reflect** (search) — get an LLM-synthesized, memory-grounded answer (`POST /reflect`).
|
||||
|
||||
### Triggers (instant, via REST Hooks)
|
||||
|
||||
Each subscribes to Hindsight's webhook API (`POST /webhooks`) and is removed on teardown (`DELETE /webhooks/{id}`):
|
||||
|
||||
- **Retain Completed** — `retain.completed`
|
||||
- **Consolidation Completed** — `consolidation.completed`
|
||||
- **Memory Defense Triggered** — `memory_defense.triggered`
|
||||
|
||||
The **Bank** field on every action/trigger is a dynamic dropdown populated from `GET /v1/default/banks` (you can also type a new bank id — banks are created on first use).
|
||||
|
||||
## Authentication
|
||||
|
||||
API-key auth. Provide:
|
||||
|
||||
- **API Key** — your Hindsight key (starts with `hsk_`), sent as `Authorization: Bearer <key>`. Required for Hindsight Cloud; **optional** — leave it blank — for a self-hosted instance running without authentication.
|
||||
- **API URL** — defaults to Hindsight Cloud (`https://api.hindsight.vectorize.io`); set it to your own instance for self-hosted (e.g. `http://localhost:8888`).
|
||||
|
||||
### Self-hosted
|
||||
|
||||
Point **API URL** at your instance. If it runs without auth, leave **API Key** blank — no `Authorization` header is sent. Triggers also work self-hosted: they rely on your instance making an _outbound_ POST to Zapier's webhook URL, which works for any box with outbound internet (only fully air-gapped instances can't).
|
||||
|
||||
> Each trigger registers its webhook with a freshly generated HMAC secret and verifies the `X-Hindsight-Signature: sha256=<hmac>` header on every delivery, rejecting any payload whose signature doesn't match.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run validate # zapier validate — structural check, no login needed
|
||||
npm test # mocha + nock unit tests, no network
|
||||
```
|
||||
|
||||
Live checks against a real instance. `zapier invoke` reads credentials from a local
|
||||
`.env` (gitignored) — write it directly to skip the interactive `auth start`:
|
||||
|
||||
```bash
|
||||
# `zapier invoke` reads auth fields from .env with an `authData_` prefix.
|
||||
# Hindsight Cloud:
|
||||
printf "authData_apiKey=hsk_your_key\nauthData_apiUrl=https://api.hindsight.vectorize.io\n" > .env
|
||||
# …or self-hosted without auth:
|
||||
printf "authData_apiKey=\nauthData_apiUrl=http://localhost:8888\n" > .env
|
||||
|
||||
npx zapier-platform invoke auth test
|
||||
npx zapier-platform invoke trigger bankList
|
||||
npx zapier-platform invoke create retain --inputData '{"bank_id":"zapier-test","content":"hello"}'
|
||||
npx zapier-platform invoke search recall --inputData '{"bank_id":"zapier-test","query":"hello"}'
|
||||
```
|
||||
|
||||
> The CLI binary is `zapier-platform` (v19 renamed it from `zapier`). `npx zapier-platform …`
|
||||
> uses the local devDependency, so no global install or PATH setup is needed.
|
||||
|
||||
## Publishing
|
||||
|
||||
Requires a Zapier developer account (`npx zapier-platform login`); cannot run in CI without a `ZAPIER_DEPLOY_KEY`.
|
||||
|
||||
```bash
|
||||
npx zapier-platform register "Hindsight" # first time only — writes .zapierapprc (gitignored)
|
||||
npx zapier-platform push # upload the current version (private/invite testing)
|
||||
npx zapier-platform promote 1.0.0 # make a version the default for new users
|
||||
```
|
||||
|
||||
Public-directory listing is a separate, manual step through Zapier's app-review (branding, descriptions, and a dedicated Hindsight Cloud test bank + API key for reviewers).
|
||||
|
||||
> **Release note:** this integration is **not** released via the monorepo's `release-integration.sh` / `release-integration.yml` (those handle PyPI/npm). It is published to Zapier manually. `package.json` is marked `"private": true` so it can never be accidentally `npm publish`ed. Do not add `zapier` to `VALID_INTEGRATIONS` in `release-integration.sh`.
|
||||
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
|
||||
const { baseUrl, DEFAULT_API_URL } = require("./utils");
|
||||
|
||||
/**
|
||||
* Custom API-key authentication.
|
||||
*
|
||||
* Mirrors the n8n `hindsightApi` credential: an API key plus an optional base
|
||||
* URL (Hindsight Cloud by default, overridable for self-hosted). The key is
|
||||
* sent as a Bearer token by the shared `beforeRequest` middleware; the `test`
|
||||
* call below exercises the credential against a real authenticated endpoint.
|
||||
*/
|
||||
|
||||
const test = (z, bundle) => z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
|
||||
|
||||
const connectionLabel = (z, bundle) => {
|
||||
const host = ((bundle.authData && bundle.authData.apiUrl) || DEFAULT_API_URL).replace(
|
||||
/^https?:\/\//,
|
||||
""
|
||||
);
|
||||
return `Hindsight (${host})`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
type: "custom",
|
||||
test,
|
||||
connectionLabel,
|
||||
fields: [
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
// Optional so you can connect to a self-hosted instance running without
|
||||
// auth (leave blank). For Hindsight Cloud a key is required — a blank key
|
||||
// there fails the connection test (401), as expected.
|
||||
required: false,
|
||||
helpText:
|
||||
"Your Hindsight API key (starts with `hsk_`). Required for Hindsight Cloud; leave blank for a self-hosted instance running without authentication. Create one at https://ui.hindsight.vectorize.io.",
|
||||
},
|
||||
{
|
||||
key: "apiUrl",
|
||||
label: "API URL",
|
||||
type: "string",
|
||||
required: false,
|
||||
default: DEFAULT_API_URL,
|
||||
helpText:
|
||||
"Hindsight API base URL. Defaults to Hindsight Cloud; change it for a self-hosted instance (e.g. http://localhost:8888).",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const { baseUrl, parseTags, enc } = require("../utils");
|
||||
|
||||
/**
|
||||
* Retain — store content in a Hindsight memory bank.
|
||||
*
|
||||
* POST /v1/default/banks/{bank_id}/memories
|
||||
* body: { items: [{ content, context?, tags?, timestamp? }], async: false }
|
||||
*/
|
||||
const perform = async (z, bundle) => {
|
||||
const item = { content: bundle.inputData.content };
|
||||
if (bundle.inputData.context) item.context = bundle.inputData.context;
|
||||
const tags = parseTags(bundle.inputData.tags);
|
||||
if (tags.length) item.tags = tags;
|
||||
if (bundle.inputData.timestamp) item.timestamp = bundle.inputData.timestamp;
|
||||
|
||||
const response = await z.request({
|
||||
method: "POST",
|
||||
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/memories`,
|
||||
body: { items: [item], async: bundle.inputData.async === true },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
key: "retain",
|
||||
noun: "Memory",
|
||||
display: {
|
||||
label: "Retain Memory",
|
||||
description:
|
||||
"Store content in a Hindsight memory bank. Hindsight extracts facts, entities, and relationships from the text.",
|
||||
},
|
||||
operation: {
|
||||
inputFields: [
|
||||
{
|
||||
key: "bank_id",
|
||||
label: "Bank",
|
||||
required: true,
|
||||
dynamic: "bankList.bank_id.name",
|
||||
helpText:
|
||||
"The memory bank to store into. A bank is created on first use; you can also type a new bank id.",
|
||||
},
|
||||
{
|
||||
key: "content",
|
||||
label: "Content",
|
||||
type: "text",
|
||||
required: true,
|
||||
helpText: "The text to store as a memory.",
|
||||
},
|
||||
{
|
||||
key: "context",
|
||||
label: "Context",
|
||||
type: "string",
|
||||
required: false,
|
||||
helpText: "Optional context describing where this content came from.",
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
label: "Tags",
|
||||
type: "string",
|
||||
required: false,
|
||||
helpText: 'Comma-separated tags, e.g. "user:alex,scope:profile".',
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
label: "Timestamp",
|
||||
type: "datetime",
|
||||
required: false,
|
||||
helpText: "When this content occurred. Defaults to now if left blank.",
|
||||
},
|
||||
{
|
||||
key: "async",
|
||||
label: "Process asynchronously",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
default: "false",
|
||||
helpText:
|
||||
"Return immediately and process in the background. Enable for very large content that might exceed Zapier's action timeout; pair it with the 'Retain Completed' trigger to act on completion.",
|
||||
},
|
||||
],
|
||||
perform,
|
||||
sample: {
|
||||
success: true,
|
||||
bank_id: "user-123",
|
||||
items_count: 1,
|
||||
async: false,
|
||||
operation_id: "op_abc123",
|
||||
},
|
||||
outputFields: [
|
||||
{ key: "success", label: "Success", type: "boolean" },
|
||||
{ key: "bank_id", label: "Bank ID" },
|
||||
{ key: "items_count", label: "Items Count", type: "integer" },
|
||||
{ key: "operation_id", label: "Operation ID" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
|
||||
const authentication = require("./authentication");
|
||||
const { addBearerHeader, handleHttpError } = require("./middleware");
|
||||
|
||||
const retain = require("./creates/retain");
|
||||
const recall = require("./searches/recall");
|
||||
const reflect = require("./searches/reflect");
|
||||
|
||||
const bankList = require("./triggers/banks");
|
||||
const retainCompleted = require("./triggers/retainCompleted");
|
||||
const consolidationCompleted = require("./triggers/consolidationCompleted");
|
||||
const memoryDefenseTriggered = require("./triggers/memoryDefenseTriggered");
|
||||
|
||||
const App = {
|
||||
version: require("./package.json").version,
|
||||
platformVersion: require("zapier-platform-core").version,
|
||||
|
||||
// Don't let the platform auto-strip/trim input — our perform functions handle
|
||||
// empty/optional fields explicitly, and this keeps behavior predictable.
|
||||
flags: { cleanInputData: false },
|
||||
|
||||
authentication,
|
||||
|
||||
// Inject the Bearer header and normalize errors for every request.
|
||||
beforeRequest: [addBearerHeader],
|
||||
afterResponse: [handleHttpError],
|
||||
|
||||
triggers: {
|
||||
[bankList.key]: bankList,
|
||||
[retainCompleted.key]: retainCompleted,
|
||||
[consolidationCompleted.key]: consolidationCompleted,
|
||||
[memoryDefenseTriggered.key]: memoryDefenseTriggered,
|
||||
},
|
||||
|
||||
creates: {
|
||||
[retain.key]: retain,
|
||||
},
|
||||
|
||||
searches: {
|
||||
[recall.key]: recall,
|
||||
[reflect.key]: reflect,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = App;
|
||||
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Request/response middleware shared by every operation.
|
||||
*
|
||||
* `addBearerHeader` injects the Hindsight API key on every outbound request so
|
||||
* individual operations don't have to. `handleHttpError` turns non-2xx
|
||||
* responses into typed Zapier errors with a useful message.
|
||||
*/
|
||||
|
||||
const addBearerHeader = (request, z, bundle) => {
|
||||
if (bundle.authData && bundle.authData.apiKey) {
|
||||
request.headers = request.headers || {};
|
||||
request.headers.Authorization = `Bearer ${bundle.authData.apiKey}`;
|
||||
}
|
||||
return request;
|
||||
};
|
||||
|
||||
const handleHttpError = (response, z) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new z.errors.Error(
|
||||
"Invalid or unauthorized Hindsight API key.",
|
||||
"AuthenticationError",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
throw new z.errors.Error(
|
||||
`Hindsight API error ${response.status}: ${response.content}`,
|
||||
"ApiError",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
module.exports = { addBearerHeader, handleHttpError };
|
||||
+12822
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@vectorize-io/zapier-hindsight",
|
||||
"version": "1.0.0",
|
||||
"description": "Hindsight long-term memory for Zapier — retain, recall, reflect, and memory-event triggers",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vectorize-io/hindsight.git",
|
||||
"directory": "hindsight-integrations/zapier"
|
||||
},
|
||||
"homepage": "https://hindsight.vectorize.io",
|
||||
"author": {
|
||||
"name": "Vectorize",
|
||||
"email": "support@vectorize.io"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "mocha --recursive -t 10000 test",
|
||||
"validate": "zapier-platform validate",
|
||||
"build": "zapier-platform build",
|
||||
"push": "zapier-platform push"
|
||||
},
|
||||
"dependencies": {
|
||||
"zapier-platform-core": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^10.7.0",
|
||||
"nock": "^13.5.0",
|
||||
"should": "^13.2.3",
|
||||
"zapier-platform-cli": "^19.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"npm": ">=9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
|
||||
const { baseUrl, parseTags, enc } = require("../utils");
|
||||
|
||||
/**
|
||||
* Recall — search a memory bank for relevant memories.
|
||||
*
|
||||
* POST /v1/default/banks/{bank_id}/memories/recall
|
||||
* body: { query, budget, tags?, tags_match?, max_tokens? } -> { results: [...] }
|
||||
*
|
||||
* Modeled as a Zapier *search* (read-only lookup). A search must return an
|
||||
* array, so we return `results` directly.
|
||||
*/
|
||||
const perform = async (z, bundle) => {
|
||||
const body = {
|
||||
query: bundle.inputData.query,
|
||||
budget: bundle.inputData.budget || "mid",
|
||||
};
|
||||
const tags = parseTags(bundle.inputData.tags);
|
||||
if (tags.length) {
|
||||
body.tags = tags;
|
||||
body.tags_match = bundle.inputData.tags_match || "any";
|
||||
}
|
||||
if (bundle.inputData.max_tokens) body.max_tokens = bundle.inputData.max_tokens;
|
||||
|
||||
const response = await z.request({
|
||||
method: "POST",
|
||||
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/memories/recall`,
|
||||
body,
|
||||
});
|
||||
return response.data.results || [];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
key: "recall",
|
||||
noun: "Memory",
|
||||
display: {
|
||||
label: "Recall Memories",
|
||||
description:
|
||||
"Search a Hindsight memory bank for memories relevant to a natural-language query.",
|
||||
},
|
||||
operation: {
|
||||
inputFields: [
|
||||
{ key: "bank_id", label: "Bank", required: true, dynamic: "bankList.bank_id.name" },
|
||||
{
|
||||
key: "query",
|
||||
label: "Query",
|
||||
type: "string",
|
||||
required: true,
|
||||
helpText: "Natural-language query to search memories with.",
|
||||
},
|
||||
{
|
||||
key: "budget",
|
||||
label: "Budget",
|
||||
type: "string",
|
||||
required: false,
|
||||
default: "mid",
|
||||
choices: { low: "Low (fast)", mid: "Medium", high: "High (thorough)" },
|
||||
helpText: "How exhaustive the retrieval should be.",
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
label: "Tags",
|
||||
type: "string",
|
||||
required: false,
|
||||
helpText: "Comma-separated tags to filter by (leave blank for no filter).",
|
||||
},
|
||||
{
|
||||
key: "tags_match",
|
||||
label: "Tags Match",
|
||||
type: "string",
|
||||
required: false,
|
||||
default: "any",
|
||||
choices: { any: "Any", all: "All", any_strict: "Any (strict)", all_strict: "All (strict)" },
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "integer",
|
||||
required: false,
|
||||
helpText: "Maximum tokens of memories to return.",
|
||||
},
|
||||
],
|
||||
perform,
|
||||
sample: {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
text: "Marcus is a marine biologist.",
|
||||
type: "world",
|
||||
context: "research background",
|
||||
tags: ["person:marcus"],
|
||||
},
|
||||
// Recall results are returned pre-ranked by the server; the response carries
|
||||
// no numeric score, and the fact-type field is `type` (not `fact_type`).
|
||||
outputFields: [
|
||||
{ key: "id", label: "ID" },
|
||||
{ key: "text", label: "Text" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "context", label: "Context" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
const { baseUrl, enc } = require("../utils");
|
||||
|
||||
/**
|
||||
* Reflect — get an LLM-synthesized answer grounded in a bank's memories.
|
||||
*
|
||||
* POST /v1/default/banks/{bank_id}/reflect
|
||||
* body: { query, budget } -> { answer, based_on }
|
||||
*
|
||||
* Modeled as a Zapier *search*. A search must return an array, so the single
|
||||
* synthesized answer is wrapped in a one-element array with a stable `id`.
|
||||
*/
|
||||
const perform = async (z, bundle) => {
|
||||
const response = await z.request({
|
||||
method: "POST",
|
||||
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/reflect`,
|
||||
body: { query: bundle.inputData.query, budget: bundle.inputData.budget || "mid" },
|
||||
});
|
||||
const data = response.data || {};
|
||||
// The reflect response carries the synthesized answer in `text` (not `answer`);
|
||||
// surface it to Zaps under the friendlier `answer` key.
|
||||
return [{ id: "reflect", answer: data.text, based_on: data.based_on }];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
key: "reflect",
|
||||
noun: "Answer",
|
||||
display: {
|
||||
label: "Reflect",
|
||||
description:
|
||||
"Get an LLM-synthesized answer to a question, grounded in a Hindsight memory bank's memories.",
|
||||
},
|
||||
operation: {
|
||||
inputFields: [
|
||||
{ key: "bank_id", label: "Bank", required: true, dynamic: "bankList.bank_id.name" },
|
||||
{
|
||||
key: "query",
|
||||
label: "Query",
|
||||
type: "string",
|
||||
required: true,
|
||||
helpText: "The question to answer using the bank's memories.",
|
||||
},
|
||||
{
|
||||
key: "budget",
|
||||
label: "Budget",
|
||||
type: "string",
|
||||
required: false,
|
||||
default: "mid",
|
||||
choices: { low: "Low (fast)", mid: "Medium", high: "High (thorough)" },
|
||||
helpText: "How deep the synthesis should be.",
|
||||
},
|
||||
],
|
||||
perform,
|
||||
sample: {
|
||||
id: "reflect",
|
||||
answer: "Jon's favorite band is Tool.",
|
||||
based_on: { memories: [], mental_models: [] },
|
||||
},
|
||||
outputFields: [
|
||||
{ key: "id", label: "ID" },
|
||||
{ key: "answer", label: "Answer" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
require("should");
|
||||
const zapier = require("zapier-platform-core");
|
||||
const nock = require("nock");
|
||||
|
||||
const App = require("../index");
|
||||
|
||||
const appTester = zapier.createAppTester(App);
|
||||
|
||||
describe("authentication", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
|
||||
|
||||
it("tests the credential against GET /v1/default/banks with a Bearer header", async () => {
|
||||
const scope = nock("https://api.example.com", {
|
||||
reqheaders: { authorization: "Bearer hsk_test" },
|
||||
})
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [] });
|
||||
|
||||
const response = await appTester(App.authentication.test, { authData });
|
||||
response.status.should.eql(200);
|
||||
scope.isDone().should.be.true();
|
||||
});
|
||||
|
||||
it("throws an AuthenticationError on 401", async () => {
|
||||
nock("https://api.example.com").get("/v1/default/banks").reply(401, { error: "nope" });
|
||||
|
||||
await appTester(App.authentication.test, { authData }).should.be.rejectedWith(
|
||||
/Invalid or unauthorized/
|
||||
);
|
||||
});
|
||||
|
||||
it("strips a trailing slash from the API URL", async () => {
|
||||
const scope = nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [] });
|
||||
|
||||
await appTester(App.authentication.test, {
|
||||
authData: { apiKey: "hsk_test", apiUrl: "https://api.example.com/" },
|
||||
});
|
||||
scope.isDone().should.be.true();
|
||||
});
|
||||
|
||||
it("builds a connection label from the host", () => {
|
||||
const label = App.authentication.connectionLabel({}, { authData });
|
||||
label.should.eql("Hindsight (api.example.com)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
require("should");
|
||||
const zapier = require("zapier-platform-core");
|
||||
const nock = require("nock");
|
||||
|
||||
const App = require("../index");
|
||||
|
||||
const appTester = zapier.createAppTester(App);
|
||||
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
|
||||
|
||||
describe("creates.retain", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it("POSTs to the memories endpoint with content, tags, and async:false", async () => {
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/memories", {
|
||||
items: [{ content: "hello", tags: ["a", "b"] }],
|
||||
async: false,
|
||||
})
|
||||
.reply(200, { success: true, bank_id: "bank-1", items_count: 1, operation_id: "op-1" });
|
||||
|
||||
const result = await appTester(App.creates.retain.operation.perform, {
|
||||
authData,
|
||||
inputData: { bank_id: "bank-1", content: "hello", tags: "a, b" },
|
||||
});
|
||||
result.operation_id.should.eql("op-1");
|
||||
result.success.should.be.true();
|
||||
});
|
||||
|
||||
it("omits tags when none are given and includes context/timestamp when present", async () => {
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/memories", {
|
||||
items: [{ content: "hi", context: "ctx", timestamp: "2026-01-01T00:00:00Z" }],
|
||||
async: false,
|
||||
})
|
||||
.reply(200, { success: true, bank_id: "bank-1", items_count: 1 });
|
||||
|
||||
await appTester(App.creates.retain.operation.perform, {
|
||||
authData,
|
||||
inputData: {
|
||||
bank_id: "bank-1",
|
||||
content: "hi",
|
||||
context: "ctx",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("URL-encodes the bank id", async () => {
|
||||
const scope = nock("https://api.example.com")
|
||||
.post("/v1/default/banks/user%2F1/memories")
|
||||
.reply(200, { success: true });
|
||||
|
||||
await appTester(App.creates.retain.operation.perform, {
|
||||
authData,
|
||||
inputData: { bank_id: "user/1", content: "hi" },
|
||||
});
|
||||
scope.isDone().should.be.true();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
require("should");
|
||||
const zapier = require("zapier-platform-core");
|
||||
const nock = require("nock");
|
||||
|
||||
const App = require("../index");
|
||||
|
||||
const appTester = zapier.createAppTester(App);
|
||||
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
|
||||
|
||||
describe("searches.recall", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it("returns the results array (not the envelope) and defaults budget to mid", async () => {
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/memories/recall", { query: "bands", budget: "mid" })
|
||||
.reply(200, { results: [{ id: "1", text: "Tool", type: "world" }] });
|
||||
|
||||
const results = await appTester(App.searches.recall.operation.perform, {
|
||||
authData,
|
||||
inputData: { bank_id: "bank-1", query: "bands" },
|
||||
});
|
||||
results.should.be.an.Array();
|
||||
results.length.should.eql(1);
|
||||
results[0].text.should.eql("Tool");
|
||||
});
|
||||
|
||||
it("sends tags and tags_match only when tags are present", async () => {
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/memories/recall", {
|
||||
query: "q",
|
||||
budget: "high",
|
||||
tags: ["x"],
|
||||
tags_match: "all",
|
||||
})
|
||||
.reply(200, { results: [] });
|
||||
|
||||
const results = await appTester(App.searches.recall.operation.perform, {
|
||||
authData,
|
||||
inputData: { bank_id: "bank-1", query: "q", budget: "high", tags: "x", tags_match: "all" },
|
||||
});
|
||||
results.should.eql([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searches.reflect", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it("surfaces the reflect `text` field as `answer` in a one-element array", async () => {
|
||||
// The real reflect response puts the synthesized answer in `text`, not `answer`.
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/reflect", { query: "fav band?", budget: "mid" })
|
||||
.reply(200, { text: "Tool.", based_on: { memories: [] } });
|
||||
|
||||
const results = await appTester(App.searches.reflect.operation.perform, {
|
||||
authData,
|
||||
inputData: { bank_id: "bank-1", query: "fav band?" },
|
||||
});
|
||||
results.should.be.an.Array();
|
||||
results.length.should.eql(1);
|
||||
results[0].id.should.eql("reflect");
|
||||
results[0].answer.should.eql("Tool.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
require("should");
|
||||
const crypto = require("crypto");
|
||||
const zapier = require("zapier-platform-core");
|
||||
const nock = require("nock");
|
||||
|
||||
const App = require("../index");
|
||||
|
||||
const appTester = zapier.createAppTester(App);
|
||||
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
|
||||
|
||||
describe("triggers.bankList", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it("maps banks to { bank_id, name } for the dropdown", async () => {
|
||||
nock("https://api.example.com")
|
||||
.get("/v1/default/banks")
|
||||
.reply(200, { banks: [{ bank_id: "b1", name: "Bank One" }, { bank_id: "b2" }] });
|
||||
|
||||
const banks = await appTester(App.triggers.bankList.operation.perform, { authData });
|
||||
banks.should.eql([
|
||||
{ id: "b1", bank_id: "b1", name: "Bank One" },
|
||||
{ id: "b2", bank_id: "b2", name: "b2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggers.retainCompleted (REST hook)", () => {
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it("subscribes with a generated secret and returns { id, bank_id, secret }", async () => {
|
||||
nock("https://api.example.com")
|
||||
.post("/v1/default/banks/bank-1/webhooks", (body) => {
|
||||
// body carries the targetUrl, event type, enabled flag, and a hex secret.
|
||||
return (
|
||||
body.url === "https://hooks.zapier.com/abc" &&
|
||||
body.event_types[0] === "retain.completed" &&
|
||||
body.enabled === true &&
|
||||
typeof body.secret === "string" &&
|
||||
body.secret.length >= 32
|
||||
);
|
||||
})
|
||||
.reply(201, { id: "wh-1" });
|
||||
|
||||
const result = await appTester(App.triggers.retainCompleted.operation.performSubscribe, {
|
||||
authData,
|
||||
inputData: { bank_id: "bank-1" },
|
||||
targetUrl: "https://hooks.zapier.com/abc",
|
||||
});
|
||||
result.id.should.eql("wh-1");
|
||||
result.bank_id.should.eql("bank-1");
|
||||
result.secret.should.be.a.String();
|
||||
});
|
||||
|
||||
it("unsubscribes by deleting the registered webhook", async () => {
|
||||
const scope = nock("https://api.example.com")
|
||||
.delete("/v1/default/banks/bank-1/webhooks/wh-1")
|
||||
.reply(200, { success: true });
|
||||
|
||||
await appTester(App.triggers.retainCompleted.operation.performUnsubscribe, {
|
||||
authData,
|
||||
subscribeData: { id: "wh-1", bank_id: "bank-1" },
|
||||
});
|
||||
scope.isDone().should.be.true();
|
||||
});
|
||||
|
||||
it("surfaces the inbound payload when there is no secret to verify", async () => {
|
||||
const event = {
|
||||
event: "retain.completed",
|
||||
bank_id: "bank-1",
|
||||
operation_id: "op-1",
|
||||
status: "completed",
|
||||
};
|
||||
const result = await appTester(App.triggers.retainCompleted.operation.perform, {
|
||||
authData,
|
||||
cleanedRequest: event,
|
||||
});
|
||||
result.should.eql([event]);
|
||||
});
|
||||
|
||||
it("accepts a delivery with a valid HMAC signature", async () => {
|
||||
const secret = "s3cr3t";
|
||||
const raw = '{"event":"retain.completed","bank_id":"bank-1"}';
|
||||
const sig = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
|
||||
|
||||
const result = await appTester(App.triggers.retainCompleted.operation.perform, {
|
||||
authData,
|
||||
subscribeData: { id: "wh-1", bank_id: "bank-1", secret },
|
||||
cleanedRequest: { event: "retain.completed", bank_id: "bank-1" },
|
||||
rawRequest: { content: raw, headers: { "X-Hindsight-Signature": sig } },
|
||||
});
|
||||
result[0].event.should.eql("retain.completed");
|
||||
});
|
||||
|
||||
it("rejects a delivery with a bad HMAC signature", async () => {
|
||||
await appTester(App.triggers.retainCompleted.operation.perform, {
|
||||
authData,
|
||||
subscribeData: { id: "wh-1", bank_id: "bank-1", secret: "s3cr3t" },
|
||||
cleanedRequest: { event: "retain.completed", bank_id: "bank-1" },
|
||||
rawRequest: {
|
||||
content: '{"event":"retain.completed","bank_id":"bank-1"}',
|
||||
headers: { "X-Hindsight-Signature": "sha256=deadbeef" },
|
||||
},
|
||||
}).should.be.rejectedWith(/signature verification failed/i);
|
||||
});
|
||||
|
||||
it("returns a sample from performList", async () => {
|
||||
const result = await appTester(App.triggers.retainCompleted.operation.performList, {
|
||||
authData,
|
||||
});
|
||||
result.should.be.an.Array();
|
||||
result[0].event.should.eql("retain.completed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { baseUrl, enc } = require("../utils");
|
||||
|
||||
/**
|
||||
* Builds a Zapier REST Hook trigger backed by Hindsight's webhook API.
|
||||
*
|
||||
* On subscribe we register a Hindsight webhook scoped to a single bank and
|
||||
* event type, pointed at Zapier's per-Zap `targetUrl`, with a freshly generated
|
||||
* HMAC secret. On unsubscribe we delete it. Each inbound delivery's signature is
|
||||
* verified before the event reaches the Zap.
|
||||
*
|
||||
* Signature: Hindsight signs the raw delivery body and sends
|
||||
* `X-Hindsight-Signature: sha256=<hex>` where `<hex>` is
|
||||
* HMAC-SHA256(secret, rawBody). We recompute it over `bundle.rawRequest.content`
|
||||
* (the body is sent byte-for-byte, so this matches) and reject on mismatch.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /v1/default/banks/{bank_id}/webhooks -> { id, ... }
|
||||
* DELETE /v1/default/banks/{bank_id}/webhooks/{webhook_id}
|
||||
*/
|
||||
|
||||
/** Case-insensitive header lookup (Zapier may lower-case header keys). */
|
||||
const getHeader = (headers, name) => {
|
||||
if (!headers) return undefined;
|
||||
const want = name.toLowerCase();
|
||||
for (const k of Object.keys(headers)) {
|
||||
if (k.toLowerCase() === want) return headers[k];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const makeHookTrigger = ({ key, noun, label, description, eventType, sample }) => {
|
||||
const performSubscribe = async (z, bundle) => {
|
||||
const secret = crypto.randomBytes(32).toString("hex");
|
||||
const response = await z.request({
|
||||
method: "POST",
|
||||
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/webhooks`,
|
||||
body: {
|
||||
url: bundle.targetUrl,
|
||||
event_types: [eventType],
|
||||
enabled: true,
|
||||
secret,
|
||||
},
|
||||
});
|
||||
// Persisted as `bundle.subscribeData` for unsubscribe + signature checks.
|
||||
return { id: response.data.id, bank_id: bundle.inputData.bank_id, secret };
|
||||
};
|
||||
|
||||
const performUnsubscribe = async (z, bundle) => {
|
||||
const { id, bank_id } = bundle.subscribeData;
|
||||
const response = await z.request({
|
||||
method: "DELETE",
|
||||
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bank_id)}/webhooks/${enc(id)}`,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Inbound delivery — verify the HMAC signature, then surface the parsed event.
|
||||
const perform = (z, bundle) => {
|
||||
const secret = bundle.subscribeData && bundle.subscribeData.secret;
|
||||
if (secret) {
|
||||
const raw = (bundle.rawRequest && bundle.rawRequest.content) || "";
|
||||
const got = getHeader(
|
||||
bundle.rawRequest && bundle.rawRequest.headers,
|
||||
"X-Hindsight-Signature"
|
||||
);
|
||||
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
|
||||
if (got !== expected) {
|
||||
throw new z.errors.Error("Webhook signature verification failed.", "SignatureError", 401);
|
||||
}
|
||||
}
|
||||
return [bundle.cleanedRequest];
|
||||
};
|
||||
|
||||
// No "list past events" endpoint exists, so the test step returns a sample.
|
||||
const performList = () => [sample];
|
||||
|
||||
return {
|
||||
key,
|
||||
noun,
|
||||
display: { label, description },
|
||||
operation: {
|
||||
type: "hook",
|
||||
inputFields: [
|
||||
{
|
||||
key: "bank_id",
|
||||
label: "Bank",
|
||||
required: true,
|
||||
dynamic: "bankList.bank_id.name",
|
||||
helpText: "The memory bank to watch for events.",
|
||||
},
|
||||
],
|
||||
performSubscribe,
|
||||
performUnsubscribe,
|
||||
perform,
|
||||
performList,
|
||||
sample,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { makeHookTrigger };
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
const { baseUrl } = require("../utils");
|
||||
|
||||
/**
|
||||
* Hidden trigger that powers the "Bank" dynamic dropdown used by every action
|
||||
* and trigger. Referenced as `dynamic: 'bankList.bank_id.name'`.
|
||||
*
|
||||
* GET /v1/default/banks -> { banks: [{ bank_id, name, ... }] }
|
||||
*/
|
||||
const perform = async (z, bundle) => {
|
||||
const response = await z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
|
||||
// Zapier requires an `id` on every trigger result (its dedup key); bank_id is
|
||||
// unique, so reuse it. The dropdown ref `bankList.bank_id.name` uses bank_id
|
||||
// as the value and name as the label.
|
||||
return (response.data.banks || []).map((b) => ({
|
||||
id: b.bank_id,
|
||||
bank_id: b.bank_id,
|
||||
name: b.name || b.bank_id,
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
key: "bankList",
|
||||
noun: "Bank",
|
||||
display: {
|
||||
label: "List Banks",
|
||||
description: "Internal trigger that populates the Bank dropdown.",
|
||||
hidden: true,
|
||||
},
|
||||
operation: {
|
||||
perform,
|
||||
canPaginate: false,
|
||||
sample: { bank_id: "user-123", name: "User 123" },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
|
||||
const { makeHookTrigger } = require("./_hookFactory");
|
||||
|
||||
module.exports = makeHookTrigger({
|
||||
key: "consolidationCompleted",
|
||||
noun: "Consolidation",
|
||||
label: "Consolidation Completed",
|
||||
description:
|
||||
"Triggers when memory consolidation finishes — observations and mental models have been synthesized.",
|
||||
eventType: "consolidation.completed",
|
||||
sample: {
|
||||
event: "consolidation.completed",
|
||||
bank_id: "user-123",
|
||||
operation_id: "op_def456",
|
||||
status: "completed",
|
||||
timestamp: "2026-06-10T12:05:00Z",
|
||||
data: {
|
||||
observations_created: 2,
|
||||
observations_updated: 1,
|
||||
observations_deleted: 0,
|
||||
error_message: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
|
||||
const { makeHookTrigger } = require("./_hookFactory");
|
||||
|
||||
module.exports = makeHookTrigger({
|
||||
key: "memoryDefenseTriggered",
|
||||
noun: "Memory Defense",
|
||||
label: "Memory Defense Triggered",
|
||||
description:
|
||||
"Triggers when Hindsight's memory-defense filter redacts or blocks incoming content (e.g. detected secrets or PII).",
|
||||
eventType: "memory_defense.triggered",
|
||||
sample: {
|
||||
event: "memory_defense.triggered",
|
||||
bank_id: "user-123",
|
||||
operation_id: "op_ghi789",
|
||||
status: "completed",
|
||||
timestamp: "2026-06-10T12:10:00Z",
|
||||
data: {
|
||||
action: "redact",
|
||||
detector: "secret_scanner",
|
||||
document_id: "doc-2",
|
||||
matched_types: ["api_key"],
|
||||
message: "Redacted 1 secret before storing.",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
const { makeHookTrigger } = require("./_hookFactory");
|
||||
|
||||
module.exports = makeHookTrigger({
|
||||
key: "retainCompleted",
|
||||
noun: "Retain",
|
||||
label: "Retain Completed",
|
||||
description:
|
||||
"Triggers when an asynchronous retain operation finishes processing in a memory bank.",
|
||||
eventType: "retain.completed",
|
||||
sample: {
|
||||
event: "retain.completed",
|
||||
bank_id: "user-123",
|
||||
operation_id: "op_abc123",
|
||||
status: "completed",
|
||||
timestamp: "2026-06-10T12:00:00Z",
|
||||
data: { document_id: "doc-1", tags: ["user:jon"] },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_API_URL = "https://api.hindsight.vectorize.io";
|
||||
|
||||
/**
|
||||
* Base URL for API calls. Defaults to Hindsight Cloud; trailing slash is
|
||||
* stripped so path concatenation never produces a double slash (mirrors the
|
||||
* n8n node's URL handling).
|
||||
*/
|
||||
const baseUrl = (bundle) =>
|
||||
((bundle.authData && bundle.authData.apiUrl) || DEFAULT_API_URL).replace(/\/$/, "");
|
||||
|
||||
/** Parse a comma-separated tag string into a trimmed, non-empty array. */
|
||||
const parseTags = (raw) =>
|
||||
!raw
|
||||
? []
|
||||
: String(raw)
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_API_URL,
|
||||
baseUrl,
|
||||
parseTags,
|
||||
enc: encodeURIComponent,
|
||||
};
|
||||
Reference in New Issue
Block a user